Skip to content

Pre-touch pages concurrently during pinned memory pool initialization - #23457

Open
rishic3 wants to merge 11 commits into
rapidsai:mainfrom
rishic3:parallel-pinned-pool-init-20260725
Open

Pre-touch pages concurrently during pinned memory pool initialization#23457
rishic3 wants to merge 11 commits into
rapidsai:mainfrom
rishic3:parallel-pinned-pool-init-20260725

Conversation

@rishic3

@rishic3 rishic3 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Description

Contributes to NVIDIA/cudf-spark#15145. Closes #23477.

This adds a parallel pre-touch initialization mode to the Java pinned memory pool. By default, the RMM pinned pool makes a single cudaHostAlloc for the requested region, wherein the CUDA driver is responsible for physically backing and pinning all the pages. In the new mode, we parallelize the physical backing of the pages by having many threads pre-touch the pages, serving page faults concurrently; the allocation can then be pinned and mapped with cudaHostRegister. Furthermore, the new initialization mode requests huge pages for the pinned allocation so when possible there are even fewer page faults to service.

Here are the initialization times on a single EC2 g6.4xlarge (16 vcpus):

Pool size Default 2 threads 4 threads 8 threads 16 threads
1 GiB 492.269 ms 107.991 ms 96.741 ms 100.641 ms 100.064 ms
2 GiB 986.617 ms 212.127 ms 190.602 ms 197.416 ms 195.621 ms
4 GiB 1,967.519 ms 423.527 ms 376.606 ms 395.128 ms 386.095 ms
8 GiB 3,935.612 ms 866.774 ms 796.490 ms 781.766 ms 775.663 ms
16 GiB 7,862.956 ms 1,692.521 ms 1,545.131 ms 1,554.749 ms 1,557.978 ms

...and the same numbers in graph form:

pinned-pool-huge-gated

Here is the marginal speedup we get from enabling huge pages on top of parallelism (green lines are huge-page-enabled, pink are not) -- huge pages help at basically all thread counts and it evens out at 16 threads:

pinned-pool-huge-vs-no-huge

Checklist

  • I am familiar with the Contributing Guidelines.
  • New or existing tests cover these changes.
  • The documentation is up to date with these changes.

@rishic3
rishic3 requested a review from a team as a code owner July 28, 2026 15:04
@copy-pr-bot

copy-pr-bot Bot commented Jul 28, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@github-actions github-actions Bot added the Java Affects Java cuDF API. label Jul 28, 2026
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Pinned memory pools now support configurable parallel initialization.
    • Larger pools can be prepared using multiple initialization threads.
    • Existing single-threaded initialization remains available by default.
  • Bug Fixes

    • Invalid pool sizes and thread counts are rejected with clear errors.
    • Pool initialization now supports non-page-aligned sizes.
    • Improved handling of allocation failures and cleanup during pinned memory setup and release.

Walkthrough

Changes

Pinned-memory pool initialization now accepts a positive thread count. Java selects single-thread or parallel RMM resources. JNI adds parallel page touching, CUDA registration, cleanup, and validation. Tests cover allocation, alignment, and invalid arguments.

Parallel pinned-memory pool

Layer / File(s) Summary
Java initialization API
java/src/main/java/ai/rapids/cudf/PinnedMemoryPool.java, java/src/main/java/ai/rapids/cudf/Rmm.java
Adds the initialization-thread overload, validates positive values, propagates the count through asynchronous construction, and selects the corresponding RMM resource.
Native parallel pinned resource
java/src/main/native/src/RmmJni.cpp
Adds a CUDA-registered memory resource with multithreaded page touching, rollback handling, failure logging, and unregister/unmap cleanup.
Pool behavior validation
java/src/test/java/ai/rapids/cudf/PinnedMemoryPoolTest.java
Tests parallel initialization, non-page-aligned sizes, allocation and byte access, fallback allocation, and invalid arguments.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

  • rapidsai/rmm#2496 — Describes the parallel page pre-touching and cudaHostRegister approach implemented here.

Possibly related PRs

  • rapidsai/cudf#23128 — Modifies pinned-memory pool and resource initialization APIs that connect to this parallel initialization work.

Suggested labels: improvement

Suggested reviewers: abellina

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the primary change: concurrent page pre-touch during pinned memory pool initialization.
Description check ✅ Passed The description explains the parallel initialization mode, huge-page support, benchmarks, linked issues, and test coverage.
Linked Issues check ✅ Passed The changes implement the linked issue requirements for concurrent pre-touch, huge-page allocation, and CUDA host registration through the cuDF JNI.
Out of Scope Changes check ✅ Passed The code, JNI changes, validation, tests, and documentation-related updates remain within the linked issue scope.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
java/src/test/java/ai/rapids/cudf/PinnedMemoryPoolTest.java (1)

56-68: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider a case where thread count exceeds page count.

touch_pages clamps thread_count to the page count; a tiny pool with a large initializationThreads (e.g. 4 KiB pool with 16 threads) would exercise that clamp and the single-page partition path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@java/src/test/java/ai/rapids/cudf/PinnedMemoryPoolTest.java` around lines 56
- 68, Extend initParallelPoolWithNonPageAlignedSize to use a small page-aligned
pool and an initializationThreads value greater than its page count, such as a 4
KiB pool with 16 threads. Keep the allocation and byte read/write assertions,
while ensuring the test exercises thread-count clamping and the single-page
partition path.
java/src/main/native/src/RmmJni.cpp (1)

531-546: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Consider a thread-safe strerror.

std::strerror is not thread-safe and these helpers can be invoked from concurrent deallocation paths. strerror_r/a small local buffer avoids interleaved or torn messages.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@java/src/main/native/src/RmmJni.cpp` around lines 531 - 546, Update
log_system_error_noexcept to avoid calling the non-thread-safe std::strerror
from concurrent cleanup paths. Resolve the error text with strerror_r (or an
equivalent thread-safe conversion) into a local buffer, then pass that buffer to
the existing CUDF_LOG_WARN/CUDF_LOG_ERROR calls while preserving the noexcept
protection and warning/error branching.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@java/src/main/native/src/RmmJni.cpp`:
- Around line 531-546: Update log_system_error_noexcept to avoid calling the
non-thread-safe std::strerror from concurrent cleanup paths. Resolve the error
text with strerror_r (or an equivalent thread-safe conversion) into a local
buffer, then pass that buffer to the existing CUDF_LOG_WARN/CUDF_LOG_ERROR calls
while preserving the noexcept protection and warning/error branching.

In `@java/src/test/java/ai/rapids/cudf/PinnedMemoryPoolTest.java`:
- Around line 56-68: Extend initParallelPoolWithNonPageAlignedSize to use a
small page-aligned pool and an initializationThreads value greater than its page
count, such as a 4 KiB pool with 16 threads. Keep the allocation and byte
read/write assertions, while ensuring the test exercises thread-count clamping
and the single-page partition path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: e26c1850-9a4f-4fd8-880e-7946f528a8f1

📥 Commits

Reviewing files that changed from the base of the PR and between 16f187b and a320fc3.

📒 Files selected for processing (4)
  • java/src/main/java/ai/rapids/cudf/PinnedMemoryPool.java
  • java/src/main/java/ai/rapids/cudf/Rmm.java
  • java/src/main/native/src/RmmJni.cpp
  • java/src/test/java/ai/rapids/cudf/PinnedMemoryPoolTest.java

Comment thread java/src/main/native/src/RmmJni.cpp Outdated
Comment on lines +713 to +716

// Note that these threads will inherit the caller's CPU affinity but are not individually
// affinity bound. If the calling thread is not bound to a NUMA node, the resultant pages
// could span multiple NUMA nodes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex provided some suggestions to consider

  1. Choose one target NUMA node before starting the first-touch workers. Derive
    it from either the selected GPU's local NUMA node or the initiating thread's
    existing CPU/memory placement.
  2. Make every worker allocate its pages on that node. Bind each worker's CPU
    affinity to CPUs on the target node, or apply an equivalent NUMA memory
    policy to the mapping while it is first-touched. Restore any temporary
    affinity or policy afterward.
  3. If the process spans multiple NUMA nodes and no reliable target node can be
    selected, do not silently scatter the pool. Fall back to the existing
    initialization path, require an explicit opt-in, or clearly document and
    warn about the possible long-term DMA penalty.
  4. Add a multi-NUMA test that verifies the touched pages reside on the intended
    node and measures sustained H2D and D2H bandwidth against the existing
    cudaHostAlloc pool. The current tests establish only that allocation
    succeeds and that the CPU can read and write the bytes; they do not validate
    page placement or GPU-transfer performance.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is naive and probably wrong in places, but the way I see it there are two cases to consider.

  1. the calling thread is intentionally constrained (given a non-default memory policy MPOL_BIND, MPOL_INTERLEAVE, MPOL_PREFERRED, and/or bound to CPUs on specific nodes).
  2. the calling thread is unconstrained (default memory policy MPOL_DEFAULT and unbound CPU affinity).

Case 1 is straightforward; we should respect the calling thread's constraints, and that requires no explicit action on our part since it is inherited by threads. And it would work seamlessly with a future feature that binds JVM threads on startup.

Case 2, where the caller is unbound, is where I suppose we might want to do something. Taking Codex's two suggested options:

  • Bind threads to the same node as the caller. It is reasonable to say that this is probably closer to what a single-threaded cudaHostAlloc would do, but as far as I know cudaHostAlloc makes no guarantee about sticking to the caller's node (e.g., the thread could migrate mid-call). Furthermore, there is no guarantee that the node of the caller is actually the NUMA node that is good for the GPU later on.
  • Bind threads to node(s) with affinity to the given GPU ID. That would be doing a lot more than what cudaHostAlloc does. And (as Codex pointed out) if there are multiple nodes it would not be clear, what to do, e.g., whether the user wants all memory on one node, interleaved, etc. It seems reasonable to leave that responsibility to a higher caller.

Finally this all assumes we can distinguish case 1 from case 2, but if the only lever is CPU affinity, it is not clear. E.g., if we see a mask that spans nodes {0,1}, I don't know if we should respect that assuming the user knows the GPU has affinity to both those CPUs, or if we should try to do something better because our machine only has two nodes.

So for this change I don't think there is any init time decision that is correct in general (so I think do-nothing is reasonable), and it is already opt-in on the Spark side.

Also note that if this eventually goes into RMM, I think the NUMA ID would be made an explicit part of the API (as suggested by @bdice).

@rishic3 rishic3 Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't response to suggestions 3. and 4.

clearly document and warn about the possible long-term DMA penalty

I'll surface the risk beyond this comment, to a docstring.

Add a multi-NUMA test that verifies the touched pages reside on the intended node and measures sustained H2D and D2H bandwidth

Assuming we don't do any explicit binding per above, I'll ignore this one.

@abellina abellina left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have some comments, but overall looks good to me.

}
// munmap always unmaps whole pages, so we can pass the unaligned bytes here.
if (::munmap(ptr, bytes) != 0) { log_system_error_noexcept("munmap", errno); }
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would remove ambiguity if we aligned the bytes argument, and we can remove the comment as well. + if we decide to align differently later, or request huge pages at mmap time we can factor that into the alignment code?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, though I kept an unaligned fallback to not skip the unmap.. let me know how it looks.

[[nodiscard]] bool operator==(
[[maybe_unused]] parallel_init_pinned_host_memory_resource const& other) const noexcept
{
return true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how about:

return this == std::addressof(other);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm. I copied this from RMM. I presume equality means we can do

void* ptr = a.allocate_sync(bytes);
b.deallocate_sync(ptr, bytes);

on different pools a and b.. is that violated here? I mean I think the suggestion is harmless but I wonder if it makes it appear as though we are not cross-deallocation-compatible.

auto const status = cudaHostUnregister(ptr);
if (status != cudaSuccess) {
cudaGetLastError();
log_cuda_error_noexcept("cudaHostUnregister", status);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this skips the munmap when cudaHostUnregister fails. I think we should try it anyway.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Should we be concerned about giving back a virtual memory range that CUDA still thinks is pinned? I'm guessing not if the context is screwed already...

Comment thread java/src/main/native/src/RmmJni.cpp Outdated
start_workers.store(true);
start_workers.notify_all();
for (auto& worker : workers) {
if (worker.joinable()) { worker.join(); }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should catch any exception the worker.join() could throw so we call join() on all workers. Unlikely to trigger in practice, but still would be nice to do.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, done.

@igorpeshansky igorpeshansky left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good implementation. My main question is structural: should this land in RMM instead? You filed rapidsai/rmm#2496 for exactly this, and NVIDIA/cudf-spark-jni#4593 needs the same touch loop but has already diverged from this one (e.g., it has neither a start barrier nor MADV_HUGEPAGE).

Separately, one thing that needs to be addressed before merge is the init failure path (#23457 (comment)) — the new backing allocation can fail in ways cudaHostAlloc couldn't, and the current getSingleton() makes any such failure permanent.

public static native long newPinnedPoolMemoryResource(long initSize, long maxSize);

static native long newParallelPinnedPoolMemoryResource(
long poolSize, int parallelInitializationThreads);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

newPinnedPoolMemoryResource takes both initSize and maxSize, while this API hard-codes both to poolSize. Is dropping growable controls deliberate, or is it deferred until it's actually needed (related to the visibility question #23457 (comment))?

Related: @revans2 raised concerns of fixed-size pools being stuck (unable to grow, shrink, or be returned) in NVIDIA/cudf-spark-jni#4593 (review) — making initialization cheaper is an invitation to have larger pinned pools, none of which are reclaimable. Worth calling out in the javadoc alongside the NUMA note?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Java pinned pool was already always fixed size (passing poolSize for both init and max sizes), afaik. Whether we want it to be growable I'm not sure, but the concerns seem different than a fixed-size pageable pool, since pinned allocations can always fall back to pageable.

explicit parallel_init_pinned_host_memory_resource(std::size_t initialization_threads)
: initialization_threads_{initialization_threads}
{
CUDF_EXPECTS(initialization_threads_ > 0,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not set an upper limit, and pretouch_parallel only compares to page_count, so nothing prevents the code from trying to spawn millions of threads. Since the numbers in the PR description flatten after ~4 threads, does it make sense to constrain initialization_threads_ to, e.g., std::thread::hardware_concurrency()? Also in Java_ai_rapids_cudf_Rmm_newParallelPinnedPoolMemoryResource

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. hardware_concurrency is a good idea, I've gone with that.

* page faults concurrently. After the pages are physically backed the allocation is then registered
* with CUDA. The RMM pool using this upstream resource is otherwise unchanged.
*/
class parallel_init_pinned_host_memory_resource final {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class is added within a JNI file, which seems like an anti-pattern to me (though already present here, sadly)… This would really benefit from C++-level tests (like in NVIDIA/cudf-spark-jni#4593), which require it to live in its own C++ source in a directory with a testing target (related to the RMM re-homing discussed in #23457 (comment)).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. Perhaps we can defer to a follow-up where we clean this up along with the other classes. Unless you think there is a blocking need for C++ tests in this one.

Comment thread java/src/main/java/ai/rapids/cudf/Rmm.java
Comment thread java/src/main/native/src/RmmJni.cpp
Comment thread java/src/main/java/ai/rapids/cudf/PinnedMemoryPool.java
Comment thread java/src/main/java/ai/rapids/cudf/PinnedMemoryPool.java

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to be heavily related to NVIDIA/cudf-spark-jni#4593. Most of the concerns raised there no longer apply here, but a few still do — I've called them out separately.

Since you've already said that RMM is the eventual destination for this (#23457 (comment)), why not start there, given that rapidsai/rmm#2496 is already open, and NVIDIA/cudf-spark-jni#4593 needs the same code?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Java Affects Java cuDF API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEA] Improve pinned memory pool initialization time

4 participants