Give the hot thread scheduler priority#15840
Conversation
|
I really like this approach. I haven't played with it much (I will next week) but I have a few thoughts:
Possible optimizations
I've been using puma's benchmark suite as more "real-world" benchmarks for thread scheduling. |
|
Thanks for having a look @luke-gruber! On your first set of thoughts:
The above only happens on multi-core, when thread1 and thread2 are running on different cores. Allowing thread2 to proceed results in a core-switch for the process which is quite expensive. The simple drop/acquire allows thread1 to continue IFF it's actively spinning, which can only happen if it's running on a different core. Regarding the optimizations
Thanks for the benchmarks pointer! I'll give them a try. |
It does if SNT is parked (it's considered a DNT then) so actually it does, my mistake. It looks like there's a problem with the We could always enable this optimization for the main ractor and only without
What I was worried about was if the syscall is always fast and the hot thread always steals control back, and let's say there's a loop like this: loop do
# fast syscall, hot thread always wins
endThe thread calling the syscall may never yield because the time between the next syscall is very minimal and the timer thread only checks interrupts every 10ms. Fortunately, it looks like even for fast syscalls, the hot thread doesn't always win so eventually it yields.
I guess the assumption is that if the non-hot thread acquires the mutex, the hot thread must either still be in the syscall or be spinning and not put back to sleep. This is probably true because there aren't many instructions between the wakeup of the non-hot thread and the unlock.
I was thinking about it taking the
No, it's fine as is. I was thinking it's kind of weird if a low priority thread signals another thread to run, does a fast syscall, takes control back and then makes the other thread wakeup and go right back asleep. |
|
I made similar idea in last year:
But I observed slowdown on my benchmark. My understanding of this PR is:
(in other words, on step 1, mark Th2 as stealable state until the Th2 really resumes) and I think it is same essentially with my implementation. I'll check it on my benchmark and think about the difference. |
|
@luke-gruber I just pushed a fix for the MN_THREAD issue. I confirmed the benchmark numbers are unchanged. |
|
@ko1 my first stab at this was to do exactly what you described having tried. However I found it broke some assumptions/asserts and the scope of the change increased beyond my comfort level. I started from scratch with the approach here (which you have accurately summarized) and it seemed much simpler. I agree they should behave similarly so I'm curious what benchmark you saw a regression on. |
c9e9114 to
66eec4a
Compare
|
@luke-gruber: Unfortunately I haven't gotten the PUMA benchmarks running yet. I'm having a variety of issues. It's likely my fault, as I'm also unable to run even the ruby benchmarks. What are the next steps to nudge this forward?
|
|
I would also like to see this move forward. The issue with benchmarking these types of changes is that normally we rely on ruby-bench, but it doesn't exercise threads at all. I'm looking to change that soon, but for now that's the way it is. I'll run this patch through one of Shopify's apps with shadow traffic and see what it gives us. It will take a few days to get reliable results from this and I'm not expecting a noticeable speedup but I'll post the conclusion of the results once I get them. As for other thread/scheduler benchmarks, maybe Koichi has some private ones that he uses. Either way, he would need to sign off on this change. One issue with the PR as is is the fact that another Ractor could be waiting on the
Benchmarking jobs are run on commit, and are from
No, not necessary.
No, I have some results I'll post in the redmine ticket. |
|
I see the code you're referring to where one ractor can acquire the thread_sched_lock of another ractor, but I'm not sure I understand the logic as it is today. There are two acquisition paths:
If the wakeup (sequence 2) happens in the middle of the wait (sequence 1), before the thread_sched_lock is acquired, would the wake get dropped? With the possible caveat that I don't understand the current behavior, I'm not sure this PR would make it worse?
|
|
Hey @ko1 do you have any idea how to move this forward? We finally got around to testing this on the github rails app and saw >10% improvements on context-switching, request-latencies, and cpu efficiency. |
|
Sorry for absent. I couldn't try it yet. Here is my benchmark I was tried last year and could you try it on your machine? (please replace my HOME to appropriate directory) TN = 10
LN = 1_000
wtype = :same_io
wtype = :mix_1_4
wtype = :mix_2_3
wtype = :mix_3_2
wtype = :mix_4_1
wtype = :same_calc
def io_task
case $io_op
when :read
File.read($io_file)
when :write
File.write($io_file, '*')
end
end
def fib n
if n > 1
fib(n-1) + fib(n-2)
else
1
end
end
def calc_task
fib(15)
end
def set_wtype calc_ratio # 0 to 5
calc_types = (0...calc_ratio).to_a
$task = -> tid do
case tid % 5
when *calc_types
LN.times{ calc_task }
else
LN.times{ io_task }
end
end
end
def kick type
case type
when :thread
ts = TN.times.map do |i|
Thread.new(i){|ti|
$task.call ti
}
end
ts.each(&:join)
when :serial
TN.times{ $task.call it }
else
raise
end
end
$:.unshift "/home/ko1/ruby/gems/benchmark/lib"
require 'benchmark'
Benchmark.bm(20){|x|
['/dev/null', '/tmp/null', '/home/ko1/null'].each do |io_file|
[:write, :read].each do |io_op|
$io_file, $io_op = io_file, io_op
wtypes = (0..5)
wtypes.each do |t|
set_wtype t
x.report("task:#{io_op},#{io_file}/c#{t}/serial"){ kick(:serial) }
x.report("task:#{io_op},#{io_file}/c#{t}/thread"){ kick(:thread) }
end
end
end
} |
|
The results from my mac M4 are here. There are 3 variations. The one that uses /dev/null seems the most interesting, as it's not dominated by IO. That one shows 25% reduction in real time and 75% reduction in system time. |
|
@ko1, do these results alleviate your concerns? What are the next steps to review and ultimately merge? I don't reviewers or labels or milestones assigned to this PR. I don't see anyone assigned to the ruby issue (3 months old now). It's hard to tell where this is going. |
|
@jpl-coconut maybe add it to the next meeting agenda? https://bugs.ruby-lang.org/issues/21877 |
|
@jpl-coconut I can invite you to slack channel I'm in and you can ping me easier, if you want. |
|
Thanks for the analysis @ko1! Thanks also for your offer! I do have slack. You can also reach me by email, which is in my commit header. github appYes, the >10% numbers are for the main github rails app. The reason it helps (I think) is that we have various background threads that compete for the GIL, and this reduces the frequency that hot threads get descheduled and drop their CPU cache. (Most of those threads come from standard middleware, so I don't think this should be specific to github.) BT - A/B/C frameworkThe framing you provided seems pretty reasonable. To get a sense of ABC I wrote a microbenchmark just for the io_tasks.
At 2usec, we see an improvement. At 41usec per syscall, we see a regression, and then again at 61 usec we see an improvement. That's a little surprising. However, an io_task is not just one syscall. The ruby write task is wall clock vs. efficiency.While looking closer at the above, I noticed something in the stats that I hadn't seen before. These are the 4 results that regress on this PR: Each is about 25% worse on wall-clock time. However, CPU-time is still improved (though not meaningfully so). In our case, we care more about CPU efficiency than wall-clock time. Wall-clock is dominated by requests to backend services anyway, so the small scheduling contribution won't be noticeable. However any change CPU efficiency translates directly to rubies per core. Still, I haven't seen this discrepancy in other benchmarks and can't explain it.
|
|
I spent a while debugging the gap between cpu-time and real-time and unfortunately didn't make much progress, then tried a different tack. I implemented a native method that does nothing but spin for the given number of microseconds. It seems very useful for benchmarking stuff like this. I then substituted the fileio in your benchmark with calls to this spin function. The units in the benchmark (1, 5, 10, etc.) are in microseconds. Here are the results: https://gist.github.com/jpl-coconut/e6d4a8792a4a84cf27cd0843efeef48e This confirms the regression in wall-time (but not cpu-time) on those 4 reports is somehow related to tmpfs implementation. (It's probably quite CPU intensive to keep reopening these file handles, creating these kernel objects, tearing them down, reopening them, etc.) but most of that is happening on separate threads with unknown synchronization behaviors. Using the a/b/c framing, this shows that the 'b' domain is actually empty. I'm curious what you think is a good next step, @ko1? |
|
I pushed a small update, adding a hint to bypass the sched lock drop/reacquire that concerned @luke-gru. The benchmarks I posted previously are all unchanged, but I did verify that most of them do fewer drop/acquire steps. |
|
I'm glad this is making progress! Sorry for my lack of involvement, but we had a hard time getting ruby 4 on our apps due to gem incompatibilities. I got pulled into other work, but this week I'm looking into running this with shadow traffic on one of our apps again. I will let you know the results when/if I get them, but don't wait on me please. |
|
In my branch I added two methods, spin_usec is implemented exactly as you described, and those are the results I posted, since they're the interesting ones. The sleep_usec results were identical across implementations, due to the nanosleep behavior that you called out. (It always deschedules the thread, and so the optimization in this PR does nothing.) I originally didn't bother posting the results, but they are here. |
|
Any thoughts on next steps, @ko1? |
|
We finally got some numbers we can trust from our environment. It's not the production environment, but it's similar. The numbers look really good 👍 Request time (mean)P99: 4-5% improvement P90: 4-5% improvement P75: 3-4% improvement P50: 4-5% improvement Overall: 9-10% improvement The overall improvement is higher due to the bigger absolute difference in ~P90-P99 (wider gap between the numbers). We also use the |
|
I will add to the dev meeting agenda as suggested by @p8 |
|
Are we making any progress? |
|
We concluded to accept this PR. As on your comment of the dev-meeting:
is essential problem. |
|
@ko1 We're seeing the contention in https://bugs.ruby-lang.org/issues/21685 as a critical regression in our Sidekiq deployments with 20 threads, causing Sidekiq worker processes to silently deregister from Redis under load after we upgraded to Ruby 3.3.9. Would you consider backporting this to the 3.3.x and 3.4.x releases? |
|
@stanhu A regression from what previous version of Ruby? |
|
@luke-gruber Ruby 3.2.8. I think it relates to this bug in https://bugs.ruby-lang.org/issues/20816, as I think you mentioned in https://bugs.ruby-lang.org/issues/20816#note-4. |
Backport ruby/ruby#15840 to fix https://bugs.ruby-lang.org/issues/21685 in Ruby 3.3, 3.4, and 4.0. The fix gives the hot thread scheduler priority when transferring control between threads, avoiding unnecessary core-switching and improving control-transfer efficiency. The fix lands upstream in Ruby 4.1, so the patch is only applied for >= 3.3 and < 4.1. Patches sourced from https://gitlab.com/gitlab-org/gitlab-build-images/-/merge_requests/1069. Co-Authored-By: Claude Opus 4.7 <[email protected]>
Backport ruby/ruby#15840 to fix https://bugs.ruby-lang.org/issues/21685 in Ruby 3.3, 3.4, and 4.0. The fix gives the hot thread scheduler priority when transferring control between threads, avoiding unnecessary core-switching and improving control-transfer efficiency. The fix lands upstream in Ruby 4.1, so the patch is only applied for >= 3.3 and < 4.1. Patches sourced from https://gitlab.com/gitlab-org/gitlab-build-images/-/merge_requests/1069. Co-Authored-By: Claude Opus 4.7 <[email protected]>


This is an alternative fix to issue 21685
The previous fix was here.
Whereas the old PR deferred the thread-switch, relying on a timer thread to wake up some time later and perform the switch if the original thread was still blocked, this new PR switches immediately, but gives the original thread the opportunity to retake control (aborting the transfer) if the new thread hasn't taken control yet.
Nice features of this approach:
Benchmarks (relative to 4.0)
There are 3 benchmarks. The 100usec benchmark is run twice, once with 1 thread and once with 10. That results in 4 scenarios. Each scenario is run once with 1 core enabled and once with two, resulting in 8 variations. Each variation is run twice. Standard-deviation is reported as a percentage.
Before:
After:
Benchmarks (relative to the other PR)