Sự khác biệt giữa Reduce và foldLeft / fold trong lập trình chức năng (đặc biệt là API Scala và Scala)?


Câu trả lời:


260

giảm so với gấp

Một sự khác biệt lớn, không được đề cập trong bất kỳ câu trả lời stackoverflow nào khác liên quan đến chủ đề này một cách rõ ràng, là nó reducephải được cung cấp một đơn thức giao hoán , tức là một phép toán vừa có tính chất giao hoán vừa có tính chất kết hợp. Điều này có nghĩa là hoạt động có thể được thực hiện song song.

Sự khác biệt này rất quan trọng đối với Dữ liệu lớn / MPP / điện toán phân tán và toàn bộ lý do tại sao reducethậm chí tồn tại. Bộ sưu tập có thể được cắt nhỏ và reducecó thể hoạt động trên từng đoạn, sau đó reducecó thể hoạt động dựa trên kết quả của từng đoạn - trên thực tế, mức độ phân khúc không cần phải dừng sâu một cấp. Chúng tôi cũng có thể chặt từng khúc. Đây là lý do tại sao tổng các số nguyên trong danh sách là O (log N) nếu có vô số CPU.

Nếu bạn chỉ nhìn vào các chữ ký thì không có lý do gì reduceđể tồn tại bởi vì bạn có thể đạt được mọi thứ bạn có thể reducevới một foldLeft. Chức năng của foldLeftlớn hơn chức năng của reduce.

Nhưng bạn không thể song song hóa a foldLeft, vì vậy thời gian chạy của nó luôn là O (N) (ngay cả khi bạn cấp dữ liệu trong một đơn thức giao hoán). Điều này là do nó giả định rằng hoạt động không phải là một đơn nguyên giao hoán và do đó giá trị tích lũy sẽ được tính bằng một loạt các tổng hợp tuần tự.

foldLeftkhông giả định tính giao hoán cũng như tính kết hợp. Tính liên kết mang lại khả năng chia nhỏ tập hợp và tính giao hoán giúp việc tích lũy trở nên dễ dàng vì thứ tự không quan trọng (vì vậy thứ tự nào để tổng hợp từng kết quả từ mỗi phần không quan trọng). Nói một cách chính xác, tính giao hoán không cần thiết cho song song, ví dụ như các thuật toán sắp xếp phân tán, nó chỉ làm cho logic dễ dàng hơn vì bạn không cần phải sắp xếp các khối của mình.

Nếu bạn xem tài liệu Spark cho reducenó cụ thể là "... toán tử nhị phân giao hoán và kết hợp"

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

Đây là bằng chứng cho thấy reduceKHÔNG chỉ là một trường hợp đặc biệt củafoldLeft

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

giảm so với gấp

Bây giờ đây là nơi nó tiến gần hơn một chút đến gốc FP / toán học và khó giải thích hơn một chút. Reduce được định nghĩa chính thức như là một phần của mô hình MapReduce, liên quan đến các tập hợp không có thứ tự (nhiều tập), Fold được định nghĩa chính thức về mặt đệ quy (xem catamorphism) và do đó giả định một cấu trúc / chuỗi cho các tập hợp.

Không có foldphương pháp nào trong Scalding vì theo mô hình lập trình Map Reduce (nghiêm ngặt), chúng ta không thể xác định foldvì các khối không có thứ tự và foldchỉ yêu cầu tính liên kết chứ không phải tính giao hoán.

Nói một cách đơn giản, reducehoạt động mà không có thứ tự tích lũy, foldyêu cầu một thứ tự tích lũy và chính thứ tự tích lũy đó yêu cầu một giá trị 0 KHÔNG phải sự tồn tại của giá trị 0 phân biệt chúng. Nói một cách chính xác là reduce nên hoạt động trên một tập hợp rỗng, bởi vì giá trị 0 của nó có thể được suy ra bằng cách lấy một giá trị tùy ý xvà sau đó giải quyết x op y = x, nhưng điều đó không hoạt động với một phép toán không giao hoán vì có thể tồn tại một giá trị 0 bên trái và bên phải khác nhau (tức là x op y != y op x). Tất nhiên Scala không bận tâm đến việc tìm ra giá trị 0 này là gì vì nó sẽ yêu cầu thực hiện một số phép toán (có thể là không thể tính được), vì vậy chỉ cần ném một ngoại lệ.

Có vẻ như (thường là trường hợp trong từ nguyên học), ý nghĩa toán học ban đầu này đã bị mất, vì sự khác biệt rõ ràng duy nhất trong lập trình là chữ ký. Kết quả là nó reduceđã trở thành một từ đồng nghĩa fold, thay vì giữ nguyên nghĩa gốc của nó từ MapReduce. Giờ đây, các thuật ngữ này thường được sử dụng thay thế cho nhau và hoạt động giống nhau trong hầu hết các triển khai (bỏ qua các tập hợp trống). Sự kỳ lạ càng trở nên trầm trọng hơn bởi những đặc thù, như trong Spark, mà bây giờ chúng ta sẽ giải quyết.

Vì vậy, Spark không có một fold, nhưng thứ tự mà sub kết quả (một cho mỗi phân vùng) được kết hợp (tại thời điểm viết bài) là thứ tự trong đó nhiệm vụ được hoàn thành - và do đó không xác định. Cảm ơn @CafeFeed đã chỉ ra cách foldsử dụng runJob, sau khi đọc qua mã, tôi nhận ra rằng nó không xác định. Sự nhầm lẫn hơn nữa được tạo ra bởi Spark có một treeReducenhưng không treeFold.

Phần kết luận

Có sự khác biệt giữa reducefoldngay cả khi áp dụng cho các chuỗi không trống. Mô hình thứ nhất được định nghĩa là một phần của mô hình lập trình MapReduce trên các tập hợp có thứ tự tùy ý ( http://theory.stanford.edu/~sergei/papers/soda10-mrc.pdf ) và người ta phải giả định rằng các toán tử là giao hoán ngoài việc liên kết để đưa ra kết quả xác định. Phần thứ hai được định nghĩa theo catomorphisms và yêu cầu các tập hợp phải có khái niệm về trình tự (hoặc được định nghĩa đệ quy, như danh sách được liên kết), do đó không yêu cầu toán tử giao hoán.

Trong thực tế, do tính chất phi toán học của lập trình reducefoldcó xu hướng hoạt động theo cùng một cách, hoặc đúng (như trong Scala) hoặc không chính xác (như trong Spark).

Thêm: Ý kiến ​​của tôi về API Spark

Ý kiến ​​của tôi là sẽ tránh được sự nhầm lẫn nếu việc sử dụng thuật ngữ foldnày bị loại bỏ hoàn toàn trong Spark. Ít nhất spark không có ghi chú trong tài liệu của họ:

Điều này hoạt động hơi khác với các hoạt động gấp được triển khai cho các bộ sưu tập không phân tán trong các ngôn ngữ chức năng như Scala.


2
Đó là lý do tại sao foldLeftcó chứa tên Lefttrong tên của nó và tại sao cũng có một phương thức được gọi fold.
kiritsuku

1
@Cloudtech Đó là sự trùng hợp ngẫu nhiên của việc triển khai theo luồng đơn, không nằm trong đặc điểm kỹ thuật của nó. Trên máy 4 lõi của tôi, nếu tôi thử thêm vào .par, vì vậy (List(1000000.0) ::: List.tabulate(100)(_ + 0.001)).par.reduce(_ / _)tôi nhận được kết quả khác nhau mỗi lần.
samthebest

2
@AlexDean trong bối cảnh khoa học máy tính, không, nó không thực sự cần danh tính vì các bộ sưu tập trống có xu hướng chỉ ném các ngoại lệ. Nhưng về mặt toán học, nó thanh lịch hơn (và sẽ thanh lịch hơn nếu các bộ sưu tập làm được điều này) nếu phần tử nhận dạng được trả về khi bộ sưu tập trống. Trong toán học, "ném một ngoại lệ" không tồn tại.
samthebest

3
@samthebest: Bạn có chắc chắn về tính giao hoán không? github.com/apache/spark/blob/… cho biết "Đối với các hàm không có tính chất giao hoán, kết quả có thể khác với kết quả của một nếp gấp được áp dụng cho một tập hợp không được phân phối."
Make42

1
@ Make42 Đúng vậy, người ta có thể viết reallyFoldma cô của riêng mình , như :, rdd.mapPartitions(it => Iterator(it.fold(zero)(f)))).collect().fold(zero)(f)điều này không cần f để đi làm.
samthebest

10

Nếu tôi không nhầm, mặc dù API Spark không yêu cầu nó, nhưng Folder cũng yêu cầu f phải có tính chất giao hoán. Bởi vì thứ tự mà các phân vùng sẽ được tổng hợp không được đảm bảo. Ví dụ trong đoạn mã sau, chỉ bản in đầu tiên được sắp xếp:

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("")(_ + _))
}  

In ra:

abcdefghijklmnopqrstuvwxyz

abcghituvjklmwxyzqrsdefnop

defghinopjklmqrstuvabcwxyz


Sau một số lần qua lại, chúng tôi tin rằng bạn đã chính xác. Thứ tự kết hợp là ai đến trước giao bóng trước. Nếu bạn chạy sc.makeRDD(0 to 9, 2).mapPartitions(it => { java.lang.Thread.sleep(new java.util.Random().nextInt(1000)); it } ).map(_.toString).fold("")(_ + _)với 2+ lõi nhiều lần, tôi nghĩ bạn sẽ thấy nó tạo ra thứ tự ngẫu nhiên (phân vùng). Tôi đã cập nhật câu trả lời của mình cho phù hợp.
samthebest

3

foldtrong Apache Spark không giống như foldtrên các bộ sưu tập không được phân phối. Trên thực tế, nó yêu cầu hàm giao hoán để tạo ra kết quả xác định:

Điều này hoạt động hơi khác với các hoạt động gấp được triển khai cho các bộ sưu tập không phân tán trong các ngôn ngữ chức năng như Scala. Thao tác gấp này có thể được áp dụng cho các phân vùng riêng lẻ và sau đó gấp các kết quả đó vào kết quả cuối cùng, thay vì áp dụng nếp gấp cho từng phần tử một cách tuần tự theo một số thứ tự xác định. Đối với các hàm không có tính chất giao hoán, kết quả có thể khác với kết quả của một nếp gấp áp dụng cho tập hợp không phân phối.

Điều này đã được thể hiện bởi Mishael Rosenthal và được Make42 gợi ý trong bình luận của mình .

Người ta cho rằng hành vi quan sát được có liên quan đến HashPartitionerthời điểm thực tế parallelizekhông xáo trộn và không sử dụng HashPartitioner.

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)

Giải thích:

Cấu trúc củafold 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
}

giống như cấu trúc củareduce 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"))
}

nơi runJobđược thực hiện mà không quan tâm đến thứ tự phân vùng và kết quả là cần hàm giao hoán.

foldPartitionreducePartitiontương đương về trình tự xử lý và hiệu quả (theo kế thừa và ủy quyền) được thực hiện bởi reduceLeftfoldLeftvề sau TraversableOnce.

Kết luận: foldtrên RDD không thể phụ thuộc vào thứ tự các khối và cần có tính giao hoán và tính liên kết .


Tôi phải thừa nhận rằng từ nguyên là khó hiểu và tài liệu lập trình đang thiếu các định nghĩa chính thức. Tôi nghĩ rằng thật an toàn khi nói rằng foldon RDDs thực sự giống như vậy reduce, nhưng điều này không tôn trọng sự khác biệt toán học cơ bản (tôi đã cập nhật câu trả lời của mình để thậm chí còn rõ ràng hơn). Mặc dù tôi không đồng ý rằng chúng ta thực sự cần tính giao hoán với điều kiện một người tin tưởng rằng bất cứ điều gì người dự tiệc của họ đang làm, đó là duy trì trật tự.
samthebest

Thứ tự không xác định của nếp gấp không liên quan đến phân vùng. Đó là hệ quả trực tiếp của việc triển khai runJob.

AH! Xin lỗi, tôi không thể tìm ra ý của bạn là gì, nhưng sau khi đọc qua runJobmã, tôi thấy rằng thực sự nó thực hiện việc kết hợp tùy theo thời điểm hoàn thành tác vụ, KHÔNG phải thứ tự của các phân vùng. Chính chi tiết quan trọng này đã làm cho mọi thứ vào đúng vị trí. Tôi đã chỉnh sửa lại câu trả lời của mình và do đó đã sửa lỗi bạn chỉ ra. Làm ơn, bạn có thể xóa tiền thưởng của mình không vì chúng tôi hiện đã đồng ý?
samthebest

Tôi không thể chỉnh sửa hoặc xóa - không có tùy chọn như vậy. Tôi có thể trao giải nhưng tôi nghĩ bạn nhận được khá nhiều điểm chỉ từ sự chú ý, tôi có nhầm không? Nếu bạn xác nhận rằng bạn muốn tôi thưởng, tôi sẽ làm điều đó trong 24 giờ tới. Cảm ơn vì đã sửa chữa và xin lỗi vì một phương pháp nhưng có vẻ như bạn bỏ qua tất cả các cảnh báo, đó là một điều lớn và câu trả lời đã được trích dẫn khắp nơi.

1
Còn bạn thì sao, bạn sẽ trao nó cho @Mishael Rosenthal vì anh ấy là người đầu tiên nói rõ mối quan tâm. Tôi không quan tâm đến các điểm, tôi chỉ thích sử dụng SO cho SEO và tổ chức.
samthebest

2

Một điểm khác biệt khác đối với Scalding là việc sử dụng các bộ kết hợp trong Hadoop.

Hãy tưởng tượng hoạt động của bạn là đơn nguyên giao hoán, với phép giảm, nó cũng sẽ được áp dụng trên bản đồ thay vì xáo trộn / sắp xếp tất cả dữ liệu vào bộ giảm. Với foldLeft, đây không phải là trường hợp.

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

Việc xác định các hoạt động của bạn là monoid trong Scalding luôn là một thông lệ tốt.

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.