Use the PTDS for (most) cupy operations - #8086
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Enterprise Run ID: 📒 Files selected for processing (4)
💤 Files with no reviewable changes (2)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughSummary by CodeRabbit
WalkthroughScope cuML internal output contexts to CuPy's per-thread default stream (PTDS), apply that scope when reflect returns internal outputs, remove several explicit null-stream synchronizations, and add a test asserting PTDS is active inside reflected/internal contexts. ChangesPTDS stream scoping and reflect integration
🎯 3 (Moderate) | ⏱️ ~20 minutes Suggested reviewers:
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
|
A quick benchmark of cross-thread performance: from time import perf_counter
from multiprocessing.pool import ThreadPool
import rmm
from cuml.linear_model import Ridge
from cuml.datasets import make_regression
rmm.mr.set_current_device_resource(
rmm.mr.PoolMemoryResource(rmm.mr.CudaAsyncMemoryResource())
)
N = 4
X, y = make_regression(100_000, 1000)
def fit(i):
Ridge(solver="svd").fit(X, y)
# Warmup
fit(1)
print(f"Benchmarking on {N} threads...")
# Measure sequential time
start = perf_counter()
for i in range(N):
fit(i)
seq_time = perf_counter() - start
print(f"- Sequential: {seq_time:.2f} s")
# Measure parallel time
with ThreadPool(N) as pool:
start = perf_counter()
pool.map(fit, range(N))
par_time = perf_counter() - start
print(f"- Parallel: {par_time:.2f} s")
print(f"\nParallel took {par_time / seq_time:.2f}x the time of sequential")This PR On Main |
|
One open question is what should our synchronization behavior be? Previously we would call Currently this PR doesn't do any synchronizing for the outputs when running with
The only risk here is users running a cuml thing that uses cupy for the last step and outputs device memory in thread 1, then does post processing in thread 2 using something other than the legacy stream without synchronizing the ptds in thread 1. This case seems pretty unlikely, but if someone was doing that before this PR they might run into concurrency issues after this PR unless we add a synchronization when returning device memory. I see two sane paths forward:
If we opt for the 2nd route as our synchronization behavior, then I think any additional work should be done in a follow-up PR (though we might want to find a place to document this in this PR). We're already correct for that model, we just overly sync. If we opt for the 1st route, then I'll need to add a defensive sync call here. My preference is the 2nd though - it's easier to implement, and better matches other cuda libraries (including cupy, which we're using as our main container and would be good to emulate the behavior of IMO). |
csadorf
left a comment
There was a problem hiding this comment.
Blocking this while I'm investigating the posed question.
|
After sleeping on it, I'm pretty convinced that option 2 is the best path forward. It's the simplest to implement (we provide a guarantee of the stream, but not of sync), theoretically allows for improved performance due to better GPU utilization, and matches with how cupy does things. Users doing typical things won't notice a change, but it allows better control for expert users on when a sync occurs. If we do the sync internally we don't leave that option open. |
|
I would also vote for option 2. |
csadorf
left a comment
There was a problem hiding this comment.
With this change, users can no longer control the stream used by cuML's Python-level CuPy work via an outer cupy.cuda.Stream() context. That's a behavior change. I think we have three options:
- Detect whether a non-default CuPy stream was previously selected and respect it.
- Provide a cuML-owned API for explicitly setting the stream, e.g.
with cuml.use_stream(...):. - Do not allow users to override this stream selection.
I think option 2 is the cleanest long-term API, but we may want a transition period where 26.06 implements option 1 to preserve existing behavior with a FutureWarning, then require the cuML-owned API in 26.08.
This PR should also add tests for the stream policy:
- the default/null-stream case should use PTDS inside
enter_internal_context() - an explicitly selected CuPy stream should have documented behavior, whether respected or intentionally ignored
One other minor thing: there are still two explicit cp.cuda.Stream.null.synchronize() calls in preprocessing/label.py that should be updated or removed. There are also four occurrences in python/cuml/tests/test_prims.py.
Regarding the synchronization question: I agree that option 2 is preferable. I tried to construct a demo using only cuML and CuPy calls that would pass on main and fail on this branch without an explicit sync, and I was not able to. So for the synchronization policy specifically, I think the practical risk is very low.
|
We just synced offline and came to the following agreement:
|
|
I've updated this PR and the description. I believe it's ready for review/merge. Once in, I'll followup with a new docs page on advanced topics. |
This configures cupy to use the per-thread default stream (PTDS) for _most_ operations. This avoids usage of the default legacy stream in more of the codebase, allowing for improved parallelism when running across multiple threads. We do this in our common decorator, which is applied to _most_ (but not all) operations. I have some future plans to refactor our decorator a bit and roll it out over more of the codebase, but for now this is an 80% solution with noticeable benefits.
dantegd
left a comment
There was a problem hiding this comment.
Love the PR and change, excited for the improvement!
dantegd
left a comment
There was a problem hiding this comment.
Forgot to change comment to approve in my review above
|
/merge |
This configures cupy to use the per-thread default stream (PTDS) for most operations. This avoids usage of the default legacy stream in more of the codebase, allowing for improved parallelism when running across multiple threads.
This is a breaking change.
Previously any cupy operations in
cumlran in cupy's default stream (the legacy stream). We didn't synchronize the stream before returning, but that didn't matter due to the synchronization behavior of the legacy stream.With this PR we've moved to running (most) cupy operations in the PTDS. Depending on the operation, we may not synchronize the PTDS before returning.
Most users shouldn't notice a difference and should have no issues.
Users not using threads, custom streams, or only working with host memory (e.g. numpy in/numpy out) should see no difference. Likewise any users that only use cupy's default stream (the legacy stream) in their code should see no issues.
Users doing tricky things with custom streams or threads may run into issues and require a manual sync of the PTDS (can be done with
cupy.cuda.Stream.ptds.synchronize(). For example, the following workflow may run into issues:For safety, you probably want to add a call to
cupy.cuda.Stream.ptds.synchronize()in thread A before returning to ensure the output array is fully populated before consuming it in thread B.Fixes #7909.