Skip to content

Symmetric Memory Pool - #2037

Closed
wdykas wants to merge 9 commits into
NVIDIA:mainfrom
wdykas:symmetric-mem-pool
Closed

wdykas wants to merge 9 commits into
NVIDIA:mainfrom
wdykas:symmetric-mem-pool

Conversation

@wdykas

@wdykas wdykas commented Aug 6, 2025

Copy link
Copy Markdown
Contributor

Description

This is a more memory efficient version for using symmetric memory all reduces. We use a pool of symmetric memory that we grow if we need it to be larger and slice into the pool.

Type of change

  • Documentation change (change only to the documentation, either a fix or a new content)
  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Infra/Build change
  • Code refactoring

Changes

Please list the changes introduced in this PR:

  • Change A
  • Change B

Checklist:

  • I have read and followed the contributing guidelines
  • The functionality is complete
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

wdykas and others added 6 commits August 6, 2025 13:11
Signed-off-by: Peter Dykas <wdykas@nvidia.com>
Signed-off-by: Peter Dykas <wdykas@nvidia.com>
Signed-off-by: Peter Dykas <wdykas@nvidia.com>
@ksivaman

Copy link
Copy Markdown
Member

/te-ci pytorch

@ksivaman

Copy link
Copy Markdown
Member

/te-ci pytorch

@nvMelissa nvMelissa added the community-contribution PRs from external contributor outside the core maintainers, representing community-driven work. label Oct 9, 2025
@ksivaman ksivaman removed the community-contribution PRs from external contributor outside the core maintainers, representing community-driven work. label Nov 21, 2025
@ksivaman ksivaman self-assigned this Nov 21, 2025
@greptile-apps

greptile-apps Bot commented Mar 11, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 3/5

[High impact] Replaces symmetric memory cache with a pooling system.

The PR needs changes before merging because small reductions can now exhaust GPU memory, and repeated singleton access still clears cached buffers.

Findings

  1. P1 Small reductions reserve large buffers ▶
  2. P2 initialized flag never set to True ▶
  3. P2 Pool operations not protected by lock ▶

Summary

Replaces the size-keyed symmetric-memory cache with reusable buffers grouped by data type, device, and process group.

  • Symmetric all-reduces share a growable buffer for each tensor type, device, and group.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Symmetric all-reduce] --> B{Pool exists?}
    B -- No --> C[Allocate 256M elements]
    B -- Yes --> D{Input fits?}
    C --> D
    D -- No --> E[Allocate larger buffer]
    D -- Yes --> F[Take prefix view]
    E --> F
    F --> G[Copy input into buffer]
    G --> H[Run all-reduce]
    H --> I[Copy result back]
Loading

Reviews (2) · Last reviewed commit: "Merge branch 'main' into symmetric-mem-p..." · Reviewed by Greptile

Comment on lines +1829 to +1837
def __init__(self):
# Ensure __init__ is only called once
if self._initialized:
return

Note: This function always returns a 1D tensor.
self.pools: Dict[Tuple, torch.Tensor] = {} # (dtype, device, group_name) -> buffer
self.pool_sizes: Dict[Tuple, int] = {} # Track allocated sizes
self.initial_pool_size = 1024 * 1024 * 256 # 256M elements initially
self.growth_factor = 2.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.

_initialized flag never set to True

The __init__ guard checks self._initialized but never sets it to True after initialization. Since __new__ sets _initialized = False on the instance and __init__ never flips it to True, every subsequent call to SymmetricMemoryBufferPool() or get_instance() will fall through the guard and reinitialize the singleton — resetting self.pools and self.pool_sizes to empty dicts and discarding all cached symmetric memory buffers.

This directly defeats the purpose of the singleton pool. Any code path that calls SymmetricMemoryBufferPool.get_instance() (which calls cls()) will silently blow away the pool.

Suggested change
def __init__(self):
# Ensure __init__ is only called once
if self._initialized:
return
Note: This function always returns a 1D tensor.
self.pools: Dict[Tuple, torch.Tensor] = {} # (dtype, device, group_name) -> buffer
self.pool_sizes: Dict[Tuple, int] = {} # Track allocated sizes
self.initial_pool_size = 1024 * 1024 * 256 # 256M elements initially
self.growth_factor = 2.0
def __init__(self):
# Ensure __init__ is only called once
if self._initialized:
return
self.pools: Dict[Tuple, torch.Tensor] = {} # (dtype, device, group_name) -> buffer
self.pool_sizes: Dict[Tuple, int] = {} # Track allocated sizes
self.initial_pool_size = 1024 * 1024 * 256 # 256M elements initially
self.growth_factor = 2.0
self._initialized = True

Comment thread transformer_engine/pytorch/distributed.py
Comment on lines 1856 to +1868

Parameters
----------
tensor_numel : int
Number of elements in the tensor.
tensor_dtype : torch.dtype
Data type of the tensor.
tensor_device : torch.device
Device on which to allocate the tensor.
tp_group : dist_group_type
Process group for rendezvous operation.
tag : Any, optional
Optional identifier to further distinguish tensors.
# Initialize pool if it doesn't exist
if pool_key not in self.pools:
self._create_pool(
pool_key, self.initial_pool_size, tensor_dtype, tensor_device, tp_group
)

Returns
-------
torch.Tensor
A symmetric memory tensor with the specified properties.
"""
# Create a cache key based on tensor properties and group
cache_key = (tensor_numel, tensor_dtype, tensor_device, tp_group.group_name, tag)

# Check if we already have a symmetric memory tensor for this configuration
if cache_key not in symmetric_mem_cache:
# Create a new symmetric memory tensor if not in cache
msg = symm_mem.empty(
tensor_numel,
dtype=tensor_dtype,
device=tensor_device,
)
# Perform the rendezvous once for this tensor
symm_mem.rendezvous(msg, group=tp_group)
# Store in cache
symmetric_mem_cache[cache_key] = msg
else:
# Reuse the existing symmetric memory tensor
msg = symmetric_mem_cache[cache_key]
# Check if we need to grow the pool
current_size = self.pool_sizes[pool_key]
if tensor_numel > current_size:
# Grow to at least accommodate the requested size
new_size = max(int(current_size * self.growth_factor), tensor_numel)
self._grow_pool(pool_key, new_size, tensor_dtype, tensor_device, tp_group)

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.

Pool operations not protected by _lock

_lock is used in __new__ to guard singleton creation, but get_buffer_slice, _create_pool, and _grow_pool are all unprotected. A race between two threads calling get_buffer_slice with the same pool_key for the first time could cause _create_pool to be called twice, triggering a double rendezvous and corrupting the pool state.

While PyTorch training is typically driven from a single thread, adding a per-instance lock (separate from the class-level _lock) around the check-then-act sections would make the implementation safe by construction and easier to reason about.

Suggested change
Parameters
----------
tensor_numel : int
Number of elements in the tensor.
tensor_dtype : torch.dtype
Data type of the tensor.
tensor_device : torch.device
Device on which to allocate the tensor.
tp_group : dist_group_type
Process group for rendezvous operation.
tag : Any, optional
Optional identifier to further distinguish tensors.
# Initialize pool if it doesn't exist
if pool_key not in self.pools:
self._create_pool(
pool_key, self.initial_pool_size, tensor_dtype, tensor_device, tp_group
)
Returns
-------
torch.Tensor
A symmetric memory tensor with the specified properties.
"""
# Create a cache key based on tensor properties and group
cache_key = (tensor_numel, tensor_dtype, tensor_device, tp_group.group_name, tag)
# Check if we already have a symmetric memory tensor for this configuration
if cache_key not in symmetric_mem_cache:
# Create a new symmetric memory tensor if not in cache
msg = symm_mem.empty(
tensor_numel,
dtype=tensor_dtype,
device=tensor_device,
)
# Perform the rendezvous once for this tensor
symm_mem.rendezvous(msg, group=tp_group)
# Store in cache
symmetric_mem_cache[cache_key] = msg
else:
# Reuse the existing symmetric memory tensor
msg = symmetric_mem_cache[cache_key]
# Check if we need to grow the pool
current_size = self.pool_sizes[pool_key]
if tensor_numel > current_size:
# Grow to at least accommodate the requested size
new_size = max(int(current_size * self.growth_factor), tensor_numel)
self._grow_pool(pool_key, new_size, tensor_dtype, tensor_device, tp_group)
with self._instance_lock:
# Initialize pool if it doesn't exist
if pool_key not in self.pools:
self._create_pool(
pool_key, self.initial_pool_size, tensor_dtype, tensor_device, tp_group
)
# Check if we need to grow the pool
current_size = self.pool_sizes[pool_key]
if tensor_numel > current_size:
# Grow to at least accommodate the requested size
new_size = max(int(current_size * self.growth_factor), tensor_numel)
self._grow_pool(pool_key, new_size, tensor_dtype, tensor_device, tp_group)
# Return a view/slice of the buffer
buffer = self.pools[pool_key]
return buffer[:tensor_numel]

(You would also need to add self._instance_lock = threading.Lock() in __init__.)

@Sunt-ing

Copy link
Copy Markdown

Hi @wdykas, I hit the same cache growth while fixing Megatron's batch-invariant TP inference and opened #3709 with a small scratch-buffer fix. Would you prefer folding this work into #2037 or proceeding with #3709?

@wdykas

wdykas commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

@Sunt-ing oh wow that PR got list in the fold at some point. Lets just go with yours mine is like over a year old

@ksivaman
ksivaman self-requested a review as a code owner October 19, 2026 16:22
@github-actions github-actions Bot added the community-contribution PRs from external contributor outside the core maintainers, representing community-driven work. label Oct 19, 2026
Comment on lines +1918 to +1921
if pool_key not in self.pools:
self._create_pool(
pool_key, self.initial_pool_size, tensor_dtype, tensor_device, tp_group
)

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.

P1 Small reductions reserve large buffers

Every new pool allocates 256M elements before checking the requested size. Even a small all-reduce now reserves 512 MiB for BF16 or 1 GiB for FP32 per device and process group. If a training run has less spare GPU memory, its first symmetric all-reduce can fail even though the old cache allocated only the requested size.

Size the first buffer from tensor_numel. This also avoids allocating an undersized buffer immediately before growing it.

@ksivaman

Copy link
Copy Markdown
Member

Closing in favor of #3709.

Claude also spots a use-after-free bug in this line, which makes sense as it is possible for that buffer to still have the old address.

@ksivaman ksivaman closed this Oct 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-contribution PRs from external contributor outside the core maintainers, representing community-driven work.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants