在并行quicksort实现中使用go例程时,性能较差
Note: The "Go-lang parallel segment runs slower than series segment" question dealt with race conditions, this one has another issue, so imho it's not a duplicate.
I'm trying to find an explanation for the following situation: Running parallel quicksort results in a significantly longer runtime when done using go routines.
Benchmarks are after the code:
package c9sort
import (
"time"
)
var runInParllel bool
func Quicksort(nums []int, parallel bool) ([]int, int) {
started := time.Now()
ch := make(chan int)
runInParllel = parallel
go quicksort(nums, ch)
sorted := make([]int, len(nums))
i := 0
for next := range ch {
sorted[i] = next
i++
}
return sorted, int(time.Since(started).Nanoseconds() / 1000000)
}
func quicksort(nums []int, ch chan int) {
// Choose first number as pivot
pivot := nums[0]
// Prepare secondary slices
smallerThanPivot := make([]int, 0)
largerThanPivot := make([]int, 0)
// Slice except pivot
nums = nums[1:]
// Go over slice and sort
for _, i := range nums {
switch {
case i <= pivot:
smallerThanPivot = append(smallerThanPivot, i)
case i > pivot:
largerThanPivot = append(largerThanPivot, i)
}
}
var ch1 chan int
var ch2 chan int
// Now do the same for the two slices
if len(smallerThanPivot) > 1 {
ch1 = make(chan int, len(smallerThanPivot))
if runInParllel {
go quicksort(smallerThanPivot, ch1)
} else {
quicksort(smallerThanPivot, ch1)
}
}
if len(largerThanPivot) > 1 {
ch2 = make(chan int, len(largerThanPivot))
if runInParllel {
go quicksort(largerThanPivot, ch2)
} else {
quicksort(largerThanPivot, ch2)
}
}
// Wait until the sorting finishes for the smaller slice
if len(smallerThanPivot) > 1 {
for i := range ch1 {
ch <- i
}
} else if len(smallerThanPivot) == 1 {
ch <- smallerThanPivot[0]
}
ch <- pivot
if len(largerThanPivot) > 1 {
for i := range ch2 {
ch <- i
}
} else if len(largerThanPivot) == 1 {
ch <- largerThanPivot[0]
}
close(ch)
}
Benchmarks for a random perm of 500000 integers:
Ran 100 times
Non parallel average - 1866ms
Parallel average - 2437ms
Any explanation would be appreciated. I know goroutines may not be best for this kind of parallelism, but I'm trying to understand the reason.
Thank you in advance.
Turns out it was very simple. As I'm on a new machine, the GOMAXPROCS variable wasn't set.
The new benchmark favors, as predicted, the parallel implementation: Set to double the number of cores:
Using 16 goroutines
Ran 100 times
Non parallel average - 1980
Parallel average - 1133
Set to the number of cores:
Using 8 goroutines
Ran 100 times
Non parallel average - 2004
Parallel average - 1197
By the way, this is fairly consistent. The average for double the number of cores is always a bit better.
Benchmark for a larger collection (1000000):
Using 8 goroutines
Ran 100 times
Non parallel average - 3748
Parallel average - 2265
With double:
Using 16 goroutines
Ran 100 times
Non parallel average - 3817
Parallel average - 2012
The general answer is that inter-thread coordination has a cost, so sending a task off to another goroutine can only speed things up if the task is at least a certain size. So don't send single items.
For a divide-and-conquer algorithm like quicksort, the ways to parallelize can be interesting. Generally: when you recurse, you can start the "sort one half of the array" task in a goroutine if it's large enough. The "if it's large enough" part is how you reduce coordination overhead.
In more detail, that looks like:
write a non-parallel
qsort(data []int)
change
qsort
to optionally take a*sync.WaitGroup
we'll callwg
for signaling that it's done. Addif wg != nil { wg.Done() }
before each of itsreturn
s.-
where
qsort
recurses, have it check whether the half of the data it's going to sort is large-ish (say, over 500 items?), and- if it's large, start another task:
wg.Add(1); go qsort(half, wg)
- if it's not, sort here and now:
qsort(half, nil)
- if it's large, start another task:
wrap
qsort
in to hide the parallel machinery from the user: like, havequicksort(data)
dowg := new(sync.WaitGroup)
, do an initialwg.Add(1)
, callqsort(data, wg)
, and dowg.Wait()
to wait for all of the background sorts to complete.
This isn't an optimal strategy, because it keeps forking off new goroutines even after there are enough tasks to keep your cores busy. And there is a lot of tuning one could do around just how you hand off some tasks to the background. But the big point is, using another thread only for large subtasks is a way to parallelize quicksort without getting smashed by coordination overhead.
Here's a demo with an embarrassingly slapdash quicksort (you have run it locally to get the timing)--good chance of bugs, definitely is quadratic on already-sorted data; the point is to get the idea of parallel divide-and-conquer across.
There is also a bottom-up strategy--sort pieces then merge--used in, for instance, this parallel sort package. The in-place merge code that package uses is tricky, though.
(And if you haven't set GOMAXPROCS, set it with something like runtime.GOMAXPROCS(runtime.NumCPU())
in main
.)
Finally, look at Go's sort package. There's a lot of code, but it's pretty clear, and once you get it you can do your experiments with a "real", fleshed-out sort implementation.