ความแตกต่างระหว่าง Reduce และ foldLeft / fold ในการเขียนโปรแกรมเชิงฟังก์ชัน (โดยเฉพาะ Scala และ Scala APIs)


96

ทำไม Scala และเฟรมเวิร์กเช่น Spark และ Scalding จึงมีทั้งสองอย่างreduceและfoldLeft? แล้วอะไรคือความแตกต่างระหว่างreduceและfold?



คำตอบ:


261

ลดเทียบกับพับซ้าย

ความแตกต่างที่ยิ่งใหญ่ซึ่งไม่ได้กล่าวถึงในคำตอบ stackoverflow อื่น ๆ ที่เกี่ยวข้องกับหัวข้อนี้อย่างชัดเจนคือreduceควรได้รับmonoid แบบสับเปลี่ยนนั่นคือการดำเนินการที่มีทั้งแบบสับเปลี่ยนและเชื่อมโยง ซึ่งหมายความว่าการดำเนินการสามารถขนานกันได้

ความแตกต่างนี้มีความสำคัญมากสำหรับ Big Data / MPP / คอมพิวเตอร์แบบกระจายและเหตุผลทั้งหมดที่ว่าทำไมreduceถึงมีอยู่ สามารถสับคอลเลกชันและreduceสามารถทำงานในแต่ละชิ้นจากนั้นreduceสามารถทำงานกับผลลัพธ์ของแต่ละชิ้น - ในความเป็นจริงระดับของการแบ่งไม่จำเป็นต้องหยุดลึกเพียงระดับเดียว เราสามารถสับแต่ละชิ้นได้ด้วย นี่คือเหตุผลที่การรวมจำนวนเต็มในรายการคือ O (log N) หากกำหนดให้ CPU มีจำนวนไม่ จำกัด

หากคุณเพียงแค่ดูลายเซ็นไม่มีเหตุผลที่reduceจะมีอยู่เพราะคุณสามารถบรรลุทุกสิ่งที่ทำได้reduceด้วยไฟล์foldLeft. การทำงานของfoldLeftเป็นมากกว่าฟังก์ชันการทำงานของreduce.

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

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

หากคุณได้ดูเอกสาร Spark ที่reduceระบุว่า "... ตัวดำเนินการไบนารีแบบสับเปลี่ยนและเชื่อมโยง"

http://spark.apache.org/docs/1.0.0/api/scala/index.html#org.apache.spark.rdd.RDD

นี่คือข้อพิสูจน์ว่าreduceไม่ใช่แค่กรณีพิเศษของfoldLeft

scala> val intParList: ParSeq[Int] = (1 to 100000).map(_ => scala.util.Random.nextInt()).par

scala> timeMany(1000, intParList.reduce(_ + _))
Took 462.395867 milli seconds

scala> timeMany(1000, intParList.foldLeft(0)(_ + _))
Took 2589.363031 milli seconds

ลดเทียบกับพับ

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

ไม่มีfoldวิธีการใดใน Scalding เนื่องจากภายใต้รูปแบบการเขียนโปรแกรมลดแผนที่ (เข้มงวด) เราไม่สามารถกำหนดได้foldเนื่องจากชิ้นส่วนไม่มีลำดับและfoldต้องใช้การเชื่อมโยงเท่านั้นไม่ใช่การสับเปลี่ยน

พูดง่ายๆว่าreduceทำงานได้โดยไม่ต้องมีลำดับของการสะสมfoldต้องมีลำดับของการสะสมและเป็นลำดับของการสะสมที่จำเป็นต้องมีค่าเป็นศูนย์ไม่ใช่การมีอยู่ของค่าศูนย์ที่แยกความแตกต่างออกไป การพูดอย่างเคร่งครัดreduce ควรใช้กับคอลเลกชันที่ว่างเปล่าเนื่องจากค่าศูนย์สามารถอนุมานได้โดยการหาค่าตามอำเภอใจxแล้วจึงแก้ปัญหาx op y = xแต่จะใช้ไม่ได้กับการดำเนินการที่ไม่สับเปลี่ยนเนื่องจากอาจมีค่าศูนย์ซ้ายและขวาที่แตกต่างกัน (กล่าวคือx op y != y op x). แน่นอนว่า Scala ไม่ต้องกังวลที่จะหาว่าค่าศูนย์นี้เป็นเท่าใดเนื่องจากต้องใช้คณิตศาสตร์บางอย่าง (ซึ่งอาจไม่สามารถคำนวณได้) ดังนั้นเพียงแค่โยนข้อยกเว้น

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

ดังนั้น Spark จึงมี a foldแต่ลำดับที่ผลลัพธ์ย่อย (หนึ่งรายการสำหรับแต่ละพาร์ติชัน) ถูกรวมเข้าด้วยกัน (ในขณะที่เขียน) เป็นลำดับเดียวกันกับที่งานจะเสร็จสมบูรณ์ - และไม่ได้ถูกกำหนด ขอบคุณ @CafeFeed ที่ชี้ให้เห็นการfoldใช้งานดังrunJobกล่าวซึ่งหลังจากอ่านโค้ดแล้วฉันก็รู้ว่ามันไม่ใช่ปัจจัยกำหนด ความสับสนนอกจากนี้ถูกสร้างขึ้นโดย Spark มีแต่ไม่มีtreeReducetreeFold

สรุป

มีความแตกต่างระหว่างreduceและfoldแม้ว่าจะใช้กับลำดับที่ไม่ว่างเปล่า อดีตถูกกำหนดให้เป็นส่วนหนึ่งของกระบวนทัศน์การเขียนโปรแกรม MapReduce บนคอลเลกชันที่มีคำสั่งตามอำเภอใจ ( http://theory.stanford.edu/~sergei/papers/soda10-mrc.pdf ) และควรถือว่าตัวดำเนินการมีการสับเปลี่ยนนอกเหนือจากการเป็น เชื่อมโยงเพื่อให้ผลลัพธ์ที่กำหนด คำหลังนี้ถูกกำหนดในรูปแบบของ catomorphisms และกำหนดให้คอลเลกชันมีความคิดของลำดับ (หรือกำหนดแบบวนซ้ำเช่นรายการที่เชื่อมโยง) จึงไม่จำเป็นต้องใช้ตัวดำเนินการสับเปลี่ยน

ในทางปฏิบัติเนื่องจากลักษณะทางคณิตศาสตร์ของการเขียนโปรแกรมreduceและfoldมีแนวโน้มที่จะทำงานในลักษณะเดียวกันไม่ว่าจะถูกต้อง (เช่นใน Scala) หรือไม่ถูกต้อง (เช่นใน Spark)

พิเศษ: ความคิดเห็นของฉันเกี่ยวกับ Spark API

ความคิดเห็นของฉันคือความสับสนจะหลีกเลี่ยงได้หากการใช้คำfoldนั้นถูกทิ้งใน Spark อย่างน้อย spark ก็มีหมายเหตุในเอกสารของพวกเขา:

สิ่งนี้ทำงานค่อนข้างแตกต่างจากการดำเนินการพับที่ใช้กับคอลเล็กชันที่ไม่กระจายในภาษาที่ใช้งานได้เช่น Scala


2
นั่นคือเหตุผลที่foldLeftมีอยู่ในชื่อและทำไมยังมีวิธีการที่เรียกว่าLeft fold
kiritsuku

1
@Cloudtech นั่นเป็นเรื่องบังเอิญของการใช้งานแบบเธรดเดียวไม่ใช่อยู่ในข้อกำหนด ในเครื่อง 4 คอร์ของฉันถ้าฉันลองเพิ่มฉัน.parจึง(List(1000000.0) ::: List.tabulate(100)(_ + 0.001)).par.reduce(_ / _)ได้ผลลัพธ์ที่แตกต่างกันในแต่ละครั้ง
samthebest

2
@AlexDean ในบริบทของวิทยาการคอมพิวเตอร์ไม่จำเป็นต้องมีตัวตนจริง ๆ เนื่องจากคอลเลกชันที่ว่างเปล่ามักจะโยนข้อยกเว้น แต่มันจะดูหรูหรากว่าในทางคณิตศาสตร์ (และจะหรูหรากว่านี้ถ้าคอลเลกชันทำเช่นนี้) หากองค์ประกอบข้อมูลประจำตัวถูกส่งคืนเมื่อคอลเล็กชันว่างเปล่า ในทางคณิตศาสตร์ไม่มี "ข้อยกเว้น"
samthebest

3
@samthebest: คุณแน่ใจเกี่ยวกับการสื่อสารหรือไม่? github.com/apache/spark/blob/…กล่าวว่า "สำหรับฟังก์ชันที่ไม่ใช่การสับเปลี่ยนผลลัพธ์อาจแตกต่างจากการพับที่ใช้กับคอลเล็กชันที่ไม่กระจาย"
42

1
@ Make42 ถูกต้องเราสามารถเขียนreallyFoldแมงดาของตัวเองได้เช่นrdd.mapPartitions(it => Iterator(it.fold(zero)(f)))).collect().fold(zero)(f)นี้ไม่จำเป็นต้องใช้ f ในการเดินทาง
samthebest

10

ถ้าฉันจำไม่ผิดแม้ว่า Spark API จะไม่ต้องการ แต่ก็ต้องมีการพับเพื่อให้ f มีการสับเปลี่ยน เนื่องจากลำดับในการรวมพาร์ติชันจะไม่มั่นใจ ตัวอย่างเช่นในรหัสต่อไปนี้จะมีการเรียงลำดับการพิมพ์ครั้งแรกเท่านั้น:

import org.apache.spark.{SparkConf, SparkContext}

object FoldExample extends App{

  val conf = new SparkConf()
    .setMaster("local[*]")
    .setAppName("Simple Application")
  implicit val sc = new SparkContext(conf)

  val range = ('a' to 'z').map(_.toString)
  val rdd = sc.parallelize(range)

  println(range.reduce(_ + _))
  println(rdd.reduce(_ + _))
  println(rdd.fold("")(_ + _))
}  

พิมพ์ออกมา:

abcdefghijklmnopqrstuvwxyz

abcghituvjklmwxyzqrsdefnop

defghinopjklmqrstuvabcwxyz


หลังจากกลับไปกลับมาเราเชื่อว่าคุณถูกต้อง ลำดับการรวมคือมาก่อนได้ก่อน หากคุณรันsc.makeRDD(0 to 9, 2).mapPartitions(it => { java.lang.Thread.sleep(new java.util.Random().nextInt(1000)); it } ).map(_.toString).fold("")(_ + _)ด้วย 2+ คอร์หลาย ๆ ครั้งฉันคิดว่าคุณจะเห็นว่ามันสร้างคำสั่งแบบสุ่ม (พาร์ติชันที่ชาญฉลาด) ฉันได้อัปเดตคำตอบตามนั้น
samthebest

3

foldใน Apache Spark ไม่เหมือนกับfoldคอลเล็กชันที่ไม่ได้แจกจ่าย ในความเป็นจริงมันต้องใช้ฟังก์ชันสับเปลี่ยนเพื่อสร้างผลลัพธ์ที่กำหนด:

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

นี้ได้รับการแสดงโดยมิชาเอลโรเซนธาลและแนะนำโดยMake42ในความคิดเห็นของเขา

จะได้รับการแนะนำว่าพฤติกรรมที่สังเกตที่เกี่ยวข้องกับHashPartitionerในเมื่อความจริงไม่ได้สับเปลี่ยนและไม่ได้ใช้parallelizeHashPartitioner

import org.apache.spark.sql.SparkSession

/* Note: standalone (non-local) mode */
val master = "spark://...:7077"  

val spark = SparkSession.builder.master(master).getOrCreate()

/* Note: deterministic order */
val rdd = sc.parallelize(Seq("a", "b", "c", "d"), 4).sortBy(identity[String])
require(rdd.collect.sliding(2).forall { case Array(x, y) => x < y })

/* Note: all posible permutations */
require(Seq.fill(1000)(rdd.fold("")(_ + _)).toSet.size == 24)

อธิบาย:

โครงสร้างfoldสำหรับ RDD

def fold(zeroValue: T)(op: (T, T) => T): T = withScope {
  var jobResult: T
  val cleanOp: (T, T) => T
  val foldPartition = Iterator[T] => T
  val mergeResult: (Int, T) => Unit
  sc.runJob(this, foldPartition, mergeResult)
  jobResult
}

เหมือนกับโครงสร้างของreduce RDD:

def reduce(f: (T, T) => T): T = withScope {
  val cleanF: (T, T) => T
  val reducePartition: Iterator[T] => Option[T]
  var jobResult: Option[T]
  val mergeResult =  (Int, Option[T]) => Unit
  sc.runJob(this, reducePartition, mergeResult)
  jobResult.getOrElse(throw new UnsupportedOperationException("empty collection"))
}

ที่runJobดำเนินการโดยไม่คำนึงถึงลำดับพาร์ติชันและส่งผลให้ต้องมีฟังก์ชันสับเปลี่ยน

foldPartitionและreducePartitionเทียบเท่าในแง่ของคำสั่งของการประมวลผลและมีประสิทธิภาพ (โดยการรับมรดกและคณะ) ดำเนินการโดยreduceLeftและบนfoldLeftTraversableOnce

สรุป: foldใน RDD ไม่สามารถขึ้นอยู่กับคำสั่งของชิ้นและความต้องการcommutativity และการเชื่อมโยงกัน


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

ลำดับการพับที่ไม่ได้กำหนดไม่เกี่ยวข้องกับการแบ่งพาร์ติชัน เป็นผลโดยตรงจากการใช้งาน runJob

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

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

1
คุณให้รางวัลกับ @Mishael Rosenthal ได้อย่างไรเนื่องจากเขาเป็นคนแรกที่ระบุข้อกังวลอย่างชัดเจน ฉันไม่มีความสนใจในประเด็นนี้ฉันชอบใช้ SO สำหรับ SEO และองค์กร
samthebest

2

ความแตกต่างอีกอย่างหนึ่งสำหรับ Scalding คือการใช้ Combiners ใน Hadoop

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

pipe.groupBy('product) {
   _.reduce('price -> 'total){ (sum: Double, price: Double) => sum + price }
   // reduce is .mapReduceMap in disguise
}

pipe.groupBy('product) {
   _.foldLeft('price -> 'total)(0.0){ (sum: Double, price: Double) => sum + price }
}

เป็นแนวทางปฏิบัติที่ดีเสมอในการกำหนดการดำเนินงานของคุณเป็นแบบ monoid ใน Scalding

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