Any idea what's going on with resource scaling and futures in this case?
I have an embarrassingly parallel workload (a bunch of replications of a simulation). I'm currently working on scaling the work out to additional cores and getting surprising speedups, which I've replicated below with a small example.
The naive thought, is that if I can assign one replication (should be an identical workload and runtime) to one core, I should be able to farm out n runs to n cores and get close to a direct speedup (n runs in 1 replication's runtime). That is, if I have N runs, and N cores, and N isolated threads (one for each core), the total batch of runs should take as much time as a single run. In practice, I realize there's still a little coordination overhead and the like (e.g. submitting tasks to run), so maybe something like 1.2x of the runtime. Maintaining result order relative to input order is not important.
(ns paralelltest
(:require [clojure.core.async :as async]))
(.availableProcessors (Runtime/getRuntime))
;;8
(defn work []
(zipmap (range 500000) (range 500000)))
(time (dotimes [i 1] (work)))
;; "Elapsed time: 210.021199 msecs"
;;submit one job.
(time (dotimes [i 1] (mapv deref (for [i (range 1)] (future (work))))))
;;"Elapsed time: 210.06426 msecs"
(time (dotimes [i 1] (mapv deref (for [i (range 8)] (future (work))))))
;;"Elapsed time: 735.863726 msecs"
(/ 735.86 210.06)
;;3.503094353994097
;;alternate implementation using core.async threads.
(defn pmap!
([n f xs]
(let [output-chan (async/chan)]
(async/pipeline-blocking n
output-chan
(map f)
(async/to-chan xs))
(async/<!! (async/into [] output-chan))))
([f xs] (pmap! (.availableProcessors (Runtime/getRuntime)) f xs)))
(time (dotimes [i 1] (pmap! 8 (fn [_] (work)) (range 8))))
;;"Elapsed time: 768.104268 msecs"
;;an unordered attempt.
(defn work-n [n f]
(let [result (promise)
xs (atom [])
_ (add-watch xs :done
(fn [_ _ old new]
(when (== (count new) n)
(deliver result new))))
submit (fn [x]
(swap! xs conj x))
workers (dotimes [i n]
(future (submit (f))))]
@result))
(time (dotimes [i 1] (work-n 1 work)))
;;"Elapsed time: 213.370527 msecs"
(time (dotimes [i 1] (work-n 8 work)))
;;"Elapsed time: 798.423232 msecs"
In this intentionally naive case, we aren't do anything requiring coordination (barring compiling the derefd results into a very small vector) so I don't see any obvious contention. Each thread is working on its own task to build a map. Yet we have (better than 8x the runtime), 3.5x the runtime where I would have expected close to 1-1.2x due to vague overhead....
I'm pretty well versed in optimizing single threaded code and digging into visualvm to get all sorts of measures, hotspots, etc. dug out. I am in unfamiliar water here with how to understand
- if there's something causing contention from visual VM output,
- whether there's an intrinsic overhead I'm missing in this use case.
My next stop would be to try java interop and directly use an executor service (also new ground for me) to see if that helps. Any insight would be welcome.
For reference, I'm running on 64 bit OpenJDK 1.8_222, 4GB max heap, intel I7 6700HQ @ 2.6 ghz, Windows 10. The phenomenon is reproducible on multiple systems though including Ubuntu. GC usage doesn't go above 1gb, there don't appear to be any collections being triggered in VisualVM.
Thanks in advance for any insight, even more so if you can point me toward a way to get better scaling here.
--Update--
On a 72-core AWS instance with 163gb max heap, the observed scaling for 72 replications is ~14x vs a single-rep batch.
So on the one hand, I'm pending 8x the effort for a ~2.28x speedup on the 8 core laptop, and on the big iron, about 72x the effort for around a 5.14x speedup.
Posted Jan 10, 2020, 10:47 PM UTC · edited
Since the workload in the topic is pure object allocation, but not cpu work. You should not expect any boost from the number of cpu ;-).
Object is allocated in heap instead of stack which is shared across threads, so in nature, this work should be >!synchronised!<. But yes our gentle JVM did something great about multithread object allocation, every thread will preserved some heap memory. If the object allocation is within the size of this preserved heap memory, the object allocation is independent to other threads. the size is calculated as
heap size / number of threads, so the speed up will depend on heap size '-Xmx' more than the number of cpu.(TLAB)
https://blogs.oracle.com/jonthecollector/a-little-thread-privacy-please
Fascinating, and makes sense. Wondering if -XX:TLABSize will help.....that or reducing allocation (which means going mutable) since almost all workloads are going to allocate on jvm, especially in clojure. Or spin up n separate jvm processes. Also consistent with u/jafingerhut conjecture.
Conundrum that going toward allocation heavy immutable style will clip your wings in parallel runs due to effectively becoming i/o bound allocation lol. This would explain many common scenarios where someone goes to use
pmapor other parallel means, and ends up running into the common 2-3x improvement wall. Thanks.resource here indicates that there's supposed to be some automatic TLAB resizing policy happening by default in response to allocation pressure.
Wow. That article is really useful and detailed explanation of how TLAB works. Thanks a lot!
Apparently you can disable TLAB with:
-XX:-UseTLAB. Would allow you to test the impact it has on performance.And a link again to one of my favorite series on the JVM: https://shipilev.net/jvm/anatomy-quarks/4-tlab-allocation/