From Queueing Theory the optimum queue structure has 1 queue feeding n servers.
This is faster than using n queues feeding n servers.
Thus the optimum parallel process has each thread|server fed input from 1 queue|producer as they become available. This is the classical Bank Teller|Grocery Store problem.
There are n bank teller stations (or grocery store cashiers). The sub-optimum strategy queues 1 line for each station (n queues). The optimal strategy is 1 queue feeding the n threads|servers.
(The U.S. grocery chain Trader Joe’s is famous for doing this, which I personally frequent.)
Thus the optimal parallel process has each spawn element sent to a current available thread. So while this code works correctly (no hanging, small constant memory use), it’s sub-optimal.
ncpus = System.cpu_count
wg = WaitGroup.new(ncpus)
channel = Channel(Tuple(Int32,Int32)).new(ncpus)
threads = Fiber::ExecutionContext::Parallel.new("threads", ncpus)
threadscnt = Atomic.new(0)
ncpus.times do
threads.spawn do
while inputs = channel.receive?
r_hi, i = inputs
lastcousins[i], cnts[i] = cousins_sieve(r_hi, ...)
print "\r#{threadscnt.add(1)} of #{pairscnt} cousinpairs done"
end
ensure
wg.done
end end
rescousins.each_with_index { |r_hi, i| channel.send({r_hi, i}) }
channel.close
wg.wait
Here the ncpus threads are spawned in parallel, then each waits to receive data.
This is the classical sub-optimal n queues for n servers strategy.
Queueing Theory, and my timing tests, establish the code below is faster.
The pairscnt jobs are fed first-come-first-serve to the n threads.
They both run in similar times for the same inputs, and are 10-15% faster as
the input values (number of spawned elements) grow.
Not only being shorter code, they’re more intuitive, and elegant.
Fiber::ExecutionContext.default.resize(maximum: System.cpu_count)
done = Channel(Nil).new(pairscnt)
threadscnt = Atomic.new(0)
restwins.each_with_index do |r_hi, i|
spawn do
lastwins[i], cnts[i] = twins_sieve(r_hi, ...)
print "\r#{threadscnt.add(1)} of #{pairscnt} twinpairs done"
done.send(nil)
end end
pairscnt.times { done.receive }
print "\r#{pairscnt} of #{pairscnt} twinpairs done"
----------------------------------------------------------------------------------
Fiber::ExecutionContext.default.resize(maximum: System.cpu_count)
wg = WaitGroup.new(pairscnt)
threadscnt = Atomic.new(0)
restwins.each_with_index do |r_hi, i|
wg.spawn do
lastwins[i], cnts[i] = twins_sieve(r_hi, ...)
print "\r#{threadscnt.add(1)} of #{pairscnt} twinpairs done"
wg.done
end end
wg.wait
However, they also hang on certain (sometime different) input values, and use increasing memory as the inputs grow, until stabilizing on some larger constant memory amount (I have 64GB).
I think what is happening is once a spawned input|fiber is sent to an available thread it doesn’t seem to immediately terminate when the thread finishes, and free up that memory. And sometimes it
appears there is fiber-to-thread contention, which causes the hanging.
Ideally you only need about ncpu number of the wg to cycle through, to be fed to the ncpu threads. Each spawn doesn’t need a separate fiber, there only need to be ncpu fibers active at any one time.
Because I don’t understand the mechanics of fiber creation|destruction I don’t know if you can create a finite fiber pool to cycle through, versus creating a separate new fiber for each spawn.
Anyway, values up to at least 10_000_000_000_000 work with the new code with no problems, with faster times and similar small memory footprints.
I hope these observations are helpful.