ทำไมซิปเร็วกว่า zip ใน Scala


38

ฉันได้เขียนโค้ดสกาล่าเพื่อดำเนินการองค์ประกอบที่ชาญฉลาดในคอลเลกชัน ที่นี่ฉันกำหนดสองวิธีที่ทำงานเดียวกัน วิธีการหนึ่งการใช้งานและการใช้งานอื่นzipzipped

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)

เพื่อเปรียบเทียบสองวิธีนี้ในแง่ของความเร็วฉันเขียนโค้ดต่อไปนี้:

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

ฉันเรียกfunวิธีการและผ่านESและES1เป็นดังนี้:

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)

ผลการศึกษาพบว่าวิธีการที่มีชื่อES1ใช้ว่าzippedจะเร็วกว่าวิธีการที่ใช้ES zipจากข้อสังเกตเหล่านี้ฉันมีสองคำถาม

ทำไมจึงzippedเร็วกว่าzip?

มีวิธีใดที่เร็วกว่านี้ในการดำเนินการกับองค์ประกอบในการรวบรวมใน Scala?


2
คำถามที่เกี่ยวข้อง: stackoverflow.com/questions/59125910/…
Mario Galic

8
เนื่องจาก JIT ตัดสินใจที่จะปรับให้เหมาะสมมากขึ้นอย่างจริงจังในครั้งที่สองที่มีการเรียกใช้ 'ความสนุก' หรือเพราะ GC ตัดสินใจล้างบางสิ่งบางอย่างในขณะที่ ES กำลังทำงาน หรือเพราะระบบปฏิบัติการของคุณตัดสินใจว่าควรทำสิ่งที่ดีกว่าในขณะที่การทดสอบ ES ของคุณทำงาน อาจเป็นอะไรก็ได้ microbenchmark นี้ไม่ได้เป็นข้อสรุป
Andrey Tyukin

1
ผลลัพธ์ของเครื่องของคุณคืออะไร? เร็วแค่ไหน
Peeyush Kushwaha

สำหรับขนาดและการกำหนดค่าประชากรเดียวกันซิปใช้เวลา 32 วินาทีขณะที่ Zip ใช้เวลา 44 วินาที
user12140540

3
ผลลัพธ์ของคุณไร้ความหมาย ใช้JMHถ้าคุณต้องทำเกณฑ์มาตรฐานไมโคร
OrangeDog

คำตอบ:


17

ในการตอบคำถามที่สองของคุณ:

มีวิธีใดที่เร็วกว่าในการทำองค์ประกอบการทำงานอย่างชาญฉลาดในคอลเล็กชั่นใน Scala หรือไม่?

ความจริงที่น่าเศร้าก็คือแม้ว่าจะมีความกระชับประสิทธิภาพที่เพิ่มขึ้นและความยืดหยุ่นต่อข้อบกพร่องที่ภาษาเชิงหน้าที่ไม่จำเป็นต้องเป็นนักแสดงมากที่สุด - การใช้ฟังก์ชันลำดับที่สูงขึ้นเพื่อกำหนดการฉายภาพที่จะดำเนินการกับคอลเลกชันที่ไม่ฟรี การจัดสรรพื้นที่เก็บข้อมูลเพิ่มเติมสำหรับผลลัพธ์ระดับกลางและสุดท้ายจะมีค่าใช้จ่าย

หากประสิทธิภาพมีความสำคัญแม้ว่าจะไม่เป็นสากล แต่อย่างใดในกรณีของคุณคุณสามารถผ่อนคลายการทำงานของ Scala กลับไปสู่การเทียบเท่าที่จำเป็นเพื่อควบคุมการใช้หน่วยความจำโดยตรงและกำจัดการเรียกใช้ฟังก์ชัน

ในตัวอย่างเฉพาะของคุณzippedจำนวนเงินที่สามารถดำเนินการได้อย่างจำเป็นโดยการจัดสรรอาร์เรย์ที่ถูกต้องและไม่แน่นอนขนาดที่ถูกต้อง (ตั้งแต่ซิปหยุดเมื่อหนึ่งในคอลเลกชันหมดองค์ประกอบ) จากนั้นเพิ่มองค์ประกอบที่ดัชนีที่เหมาะสมด้วยกัน องค์ประกอบอาเรย์โดยดัชนีลำดับเป็นการดำเนินการอย่างรวดเร็ว)

การเพิ่มฟังก์ชั่นที่สามES3ในชุดทดสอบของคุณ:

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
}

ที่ i7 ของฉันฉันได้รับเวลาตอบสนองต่อไปนี้:

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

ยิ่งร้ายยิ่งกว่านั้นจะทำการควบคุมการกลายพันธุ์ของอาร์เรย์ทั้งสองที่สั้นกว่าซึ่งจะทำให้เนื้อหาของหนึ่งในอาร์เรย์เสียหายและเห็นได้ชัดและจะต้องดำเนินการหากไม่จำเป็นต้องใช้อาร์เรย์เดิมอีก:

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

แต่เห็นได้ชัดว่าการกลายพันธุ์โดยตรงขององค์ประกอบอาเรย์ไม่ได้อยู่ในจิตวิญญาณของสกาล่า


2
ในโค้ดของฉันไม่มีอะไรขนานกัน แม้ว่าปัญหาที่เฉพาะเจาะจงนี้เป็นแบบขนาน (เนื่องจากหลายเธรดสามารถทำงานได้ในส่วนต่าง ๆ ของอาร์เรย์) แต่ก็ไม่ได้มีจุดมากนักในการดำเนินการอย่างง่าย ๆ ในองค์ประกอบ 10k เท่านั้น - ค่าใช้จ่ายในการสร้างและการซิงโครไนซ์เธรดใหม่ . หากคุณต้องการเพิ่มประสิทธิภาพในระดับนี้คุณควรเขียนอัลกอริธึมเหล่านี้ใน Rust, Go หรือ C
StuartLC

3
มันจะเป็นแบบสกาล่ามากขึ้นและเร็วขึ้นที่จะใช้Array.tabulate(minSize)(i => arr(i) + arr1(i))ในการสร้างอาร์เรย์ของคุณ
Sarvesh Kumar Singh

1
@SarveshKumarSingh อันนี้ช้ากว่ามาก ใช้เวลาเกือบ 9 วินาที
user12140540

1
Array.tabulateควรจะเร็วกว่าzipหรือzippedที่นี่อย่างใดอย่างหนึ่ง(และอยู่ในมาตรฐานของฉัน)
เทรวิสบราวน์

1
@StuartLC "ประสิทธิภาพจะเท่ากันถ้าฟังก์ชันลำดับที่สูงขึ้นนั้นยังไม่ได้เปิดและอินไลน์" มันไม่ถูกต้องจริงๆ แม้แต่ของคุณforจะถูกเรียกไปยังการเรียกใช้ฟังก์ชันที่สูงขึ้น ( foreach) แลมบ์ดาจะถูกสร้างอินสแตนซ์เพียงครั้งเดียวในทั้งสองกรณี
Travis Brown

50

ไม่มีคำตอบอื่น ๆ ที่พูดถึงเหตุผลหลักสำหรับความแตกต่างของความเร็วซึ่งเป็นzippedเวอร์ชั่นที่หลีกเลี่ยงการจัดสรร 10,000 tuple ในฐานะที่เป็นคู่ของคำตอบอื่น ๆทำโน้ตzipรุ่นเกี่ยวข้องกับอาร์เรย์กลางในขณะที่zippedรุ่นไม่ได้ แต่การจัดสรรอาร์เรย์ 10,000 องค์ประกอบไม่ได้เป็นสิ่งที่ทำให้zipรุ่นมากที่เลวร้ายยิ่งมัน 10,000 tuples สั้น ๆ ว่า กำลังถูกใส่เข้าไปในอาร์เรย์นั้น สิ่งเหล่านี้แสดงโดยวัตถุบน JVM ดังนั้นคุณจึงทำการจัดสรรวัตถุเป็นจำนวนมากสำหรับสิ่งที่คุณจะทิ้งทันที

ส่วนที่เหลือของคำตอบนี้จะอธิบายรายละเอียดเพิ่มเติมเล็กน้อยว่าคุณจะยืนยันได้อย่างไร

การเปรียบเทียบที่ดีขึ้น

คุณต้องการที่จะใช้กรอบเช่น jmhในการทำเบนช์มาร์กด้วยความรับผิดชอบบน JVM และส่วนที่รับผิดชอบก็ยากแม้ว่าการตั้งค่า jmh เองนั้นไม่ได้เลวร้ายนัก หากคุณมีสิ่งproject/plugins.sbtนี้:

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

และสิ่งbuild.sbtนี้ (ฉันใช้ 2.11.8 เนื่องจากคุณพูดถึงว่าคุณใช้อะไรอยู่):

scalaVersion := "2.11.8"

enablePlugins(JmhPlugin)

จากนั้นคุณสามารถเขียนมาตรฐานของคุณเช่นนี้:

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

และเรียกใช้ด้วย 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

ซึ่งแสดงให้เห็นว่า zippedรุ่นนั้นได้รับปริมาณงานมากขึ้นประมาณ 80% ซึ่งน่าจะมากหรือน้อยกว่าการวัดของคุณ

การวัดการจัดสรร

คุณสามารถขอให้ jmh วัดการจัดสรรด้วย-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

gc.alloc.rate.normน่าจะเป็นส่วนที่น่าสนใจที่สุดซึ่งแสดงให้เห็นว่าzipรุ่นนี้มีการจัดสรรมากกว่าสามเท่าzippedรุ่นจะจัดสรรกว่าสามเท่า

การใช้งานที่จำเป็น

หากฉันรู้ว่าวิธีนี้จะถูกเรียกในบริบทที่ไวต่อประสิทธิภาพอย่างมากฉันอาจใช้วิธีนี้:

  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
  }

โปรดทราบว่าแตกต่างจากรุ่นที่ได้รับการปรับปรุงในหนึ่งในคำตอบอื่น ๆ สิ่งนี้ใช้whileแทนคำว่าforตั้งแต่forจะยังคง desugar ในการดำเนินการรวบรวม Scala เราสามารถเปรียบเทียบการใช้งานนี้ ( withWhile) การใช้งานคำตอบอื่น ๆ ที่ได้รับการปรับปรุง (แต่ไม่ใช่ในสถานที่) ( withFor) และการใช้งานดั้งเดิมสองแบบ:

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

นั่นเป็นความแตกต่างอย่างมากระหว่างรุ่นที่จำเป็นและรุ่นใช้งานได้และลายเซ็นของวิธีการเหล่านี้เหมือนกันทุกประการและการใช้งานมีความหมายเหมือนกัน มันไม่เหมือนการใช้งานที่จำเป็นกำลังใช้สถานะทั่วโลก ฯลฯ ในขณะที่zipและzippedเวอร์ชันสามารถอ่านได้มากขึ้นฉันเองไม่คิดว่าจะมีความรู้สึกใดที่เวอร์ชันเชิงบังคับนั้นขัดกับ "วิญญาณแห่งสกาล่า" และฉันก็ไม่ลังเลเลย เพื่อใช้พวกเขาเอง

ด้วยตาราง

อัปเดต: ฉันเพิ่มการtabulateใช้งานในการวัดประสิทธิภาพตามความคิดเห็นในคำตอบอื่น:

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

มันเร็วกว่าzipรุ่นต่าง ๆ ถึงแม้ว่าจะช้ากว่ารุ่นอื่น ๆ :

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

นี่คือสิ่งที่ฉันคาดหวังเนื่องจากไม่มีอะไรแพงในการเรียกใช้ฟังก์ชันและเนื่องจากการเข้าถึงองค์ประกอบอาเรย์โดยดัชนีมีราคาถูกมาก


8

พิจารณา lazyZip

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

แทน zip

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

สกาล่า 2.13 เพิ่ม lazyZipในความโปรดปรานของ.zipped

ร่วมกับ.zipการดูแทนที่นี้.zipped(ตอนนี้เลิกใช้แล้ว) ( scala / collection-strawman # 223 )

zipped(และด้วยเหตุนี้lazyZip) จะเร็วกว่าzipเพราะอธิบายโดยทิมและไมค์อัลเลน , zipตามมาด้วยmapจะส่งผลให้ทั้งสองแปลงที่แยกจากกันเนื่องจากความเข้มงวดในขณะที่zippedตามมาด้วยmapจะส่งผลให้เกิดการเปลี่ยนแปลงที่เดียวดำเนินการในหนึ่งไปเนื่องจากความเกียจคร้าน

zippedให้Tuple2Zippedและการวิเคราะห์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()
  }

เราเห็นคอลเลกชันทั้งสองcoll1และcoll2มีการทำซ้ำซ้ำแล้วซ้ำอีกในแต่ละรอบฟังก์ชั่นที่fส่งไปยังmapถูกนำไปใช้ตลอดทาง

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

โดยไม่ต้องจัดสรรและแปลงโครงสร้างตัวกลาง


การประยุกต์ใช้เทรวิสวิธีการเปรียบเทียบที่นี่คือการเปรียบเทียบระหว่างใหม่lazyZipและเลิกzippedที่

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

จะช่วยให้

[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

lazyZipดูเหมือนว่าจะดำเนินการได้ดีขึ้นเล็กน้อยกว่าบนzipped ArraySeqที่น่าสนใจทราบผลการดำเนินงานที่เสื่อมโทรมอย่างมีนัยสำคัญเมื่อใช้บนlazyZipArray


lazyZip พร้อมใช้งานใน Scala 2.13.1 ขณะนี้ฉันกำลังใช้ Scala 2.11.8
user12140540

5

คุณควรระมัดระวังในการวัดประสิทธิภาพเนื่องจากการรวบรวม JIT แต่เหตุผลที่zippedเป็นไปได้คือขี้เกียจและแยกองค์ประกอบจากArrayvaules ดั้งเดิมระหว่างการmapโทรzipสร้างArrayวัตถุใหม่แล้วเรียกmapวัตถุใหม่

โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.