I'm opening this issue to discuss the results from our "one-thread allocating garbage" experiment, detailed here:
RelationalAI-oss/MultithreadingBenchmarks.jl#4
That experiment showcased a potential performance hazard: Having one thread running a long-running, tight loop with no allocations can essentially deadlock the rest of the program, as GC prevents any threads from scheduling tasks until every task has reached a GC-safepoint, and GC has completed.
This problem will show up if you have some long-running tasks that never allocate, and then GC is triggered on another thread. In that case, the entire program will wait until all tasks have completed. This is mostly a problem if you have an "unbalanced" workload, where some tasks are alloc-free, but other tasks allocate enough memory to trigger GC. (It could even be triggered by allocating the tasks themselves in an almost allocation-free program.)
Since, currently, GC requires all threads to be in a gc-safepoint before it will proceed, and since tasks cannot be preempted, once GC is triggered it will pause any thread that enters a GC-safepoint until _all_ threads have entered a GC-safepoint.
Switching tasks is a gc-safepoint, so in the above benchmark workload, once GC is triggered, no new queries are scheduled to execute until all currently executing queries have completed.
Note that this problem is a consequence of having non-preemptable tasks and stop-the-world GC. Golang also suffers from this problem, as discussed here: https://github.com/golang/go/issues/10958 (long thread)
In the situation I outlined above, it would simply run slowly, as the rest of the program pauses on the cpu-only thread. But one could easily imagine a deadlock if the cpu-bound task was waiting on e.g. an Atomic variable to be updated by one of the other paused tasks.
We discussed this result in-person today with @JeffBezanson and @raileywild, and I wanted to record some of our thoughts:
yield() points in their tight-loop, but of course that would make their tight code _slower_.ccall(:jl_gc_safepoint).gc_time reported by @time and similar tools _doesn't include the time spent waiting for all threads to reach a safepoint_. It probably should.gc_time, or we should add another separate metric for, like, "gc synchronization time".See #33092 for manual safepoints.
Relatedly, consider allowing the compiler to [automatically insert gc-safe transitions] out
I think this is the way to go and think it can be done well. But since it鈥檚 not going to be immediately ready, #33092 seems like the way to go right now (export what we already have, and backport it to 1.3 branch)
We could also speed-up the time to do GC by multithreading the mark-phase
IIRC, the mark code (nearly?) supports this already, so we should do this.
concurrent sweep with allocations
This seems difficult (or slow?), but (if we鈥檙e not already), we may be able to get most threads to sweep their own heap in parallel.