Skip to content

PERF: Remove GPU-CPU Synchronization in FIRE - #609

Merged
CompRhys merged 16 commits into
TorchSim:mainfrom
curtischong:rm-cpu-transfers
Oct 22, 2026
Merged

CompRhys merged 16 commits into
TorchSim:mainfrom
curtischong:rm-cpu-transfers

Conversation

@curtischong

@curtischong curtischong commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

There is a lot of GPU-CPU synchronization in the fire optimizer. This PR attempts to remove them all so everything stays on GPU.

Across 5 runs each against main and this branch, Mace small optimized 100 structures with these timings:

Revision Median time Time SD Median structures/min Throughput SD
main 12.104 s 0.165 s 495.71 6.79
PR (276d04d) 11.883 s 0.062 s 504.93 2.66

1.019× throughput: 1.86% faster, or 1.83% less runtime

For faster models, this change will be even faster.

Here are the specific optimizations we implemented:

  • We remove .item() in batched_vdot

  • We remove an if statement which caused a sync from GPU to CPU and replaced this with no-branching GPU operations

  • We no longer call state.n_systems which causes a GPU to CPU sync when we look at the size of the resulting tensor from the .unique() call.

  • Replaced x[mask] with torch.where calls. Why? It's because:

    • x[mask] *= f works by trying to ONLY do the minimal amount of multiplication needed. Unfortunately, this means that pytorch needs to dynamically resolve the shape of the indices of x resolved by mask. dynamically resolving the shape of these indices builds a temp array which sends information to the CPU. This is best explained with an example by GPT:

      For example, if mask is [True, False, True, False], PyTorch calls nonzero(mask) to produce the index tensor [0, 2] on the GPU.
      
      The CPU knows the mask has 4 entries, but doesn’t know that 2 are true. PyTorch’s CUDA implementation counts the matches, copies that count back to the CPU, and waits for the copy to finish. It can then set the index tensor’s shape to (2,) and gather x[0] and x[2].
      
    • torch.where(mask, x*f, x) on the other hand, just pre-multipled all indices of x by f before selecting by the mask. It doesn't need to wait to dynamically figure out the size of the mask. The extra multiplication is cheaper than the wait.

    Here is a script to benchmark the two approaches

    import time
    import torch
    
    mask = torch.tensor([True, False] * 4, device="cuda")
    factor = torch.tensor(0.99, device="cuda")
    
    
    def indexing(x):
        x[mask] *= factor
        return x
    
    
    def where(x):
        return torch.where(mask, x * factor, x)
    
    
    def measure(update):
        x = torch.ones(8, device="cuda")
    
        # Warm up before starting the timer.
        for _ in range(100):
            x = update(x)
        torch.cuda.synchronize()
    
        start = time.perf_counter()
        for _ in range(1000):
            x = update(x)
        torch.cuda.synchronize()  # Wait for all timed GPU work to finish.
    
        return (time.perf_counter() - start) / 1000 * 1e6
    
    
    print(f"indexing: {measure(indexing):.1f} microseconds/update")
    print(f"where:    {measure(where):.1f} microseconds/update")
    
    # output:
    # indexing: 66.7 microseconds/update
    # where:    12.2 microseconds/update


# Initialize velocities if NaN
nan_velocities = state.velocities.isnan().any(dim=1)
if nan_velocities.any():

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

this if statement triggered a sync between gpu and cpu

@curtischong curtischong changed the title v1 pass Remove GPU-CPU Synchronization in FIRE Sep 26, 2026
"""Perform one ASE-style FIRE optimization step."""
from torch_sim.optimizers import CellFireState

n_systems, device, dtype = state.n_systems, state.device, state.dtype

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

n_systems is slow. since we do a torch.unique().shape[0] call. we need to wait for the unique to run.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

maybe we should have a proper fix for this by just manually tracking the number of systems in simstate. but this feels like it can easily lead to bugs

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

can we not look at the state.cell.numel()/9 to avoid the unique call? seems a little easier to follow than state.dt.numel() and could replace the n_systems property on state level maybe?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I agree fixing it at the property level is the better way to go: store_model_extras and compute_cell_forces also read state.n_systems, so that would remove those syncs too.
We could go back to state.n_systems here instead of dt.numel() once that change lands.
Happy to do that in a separate PR if that works for you both.

@curtischong curtischong Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think Rhys's idea might be better than what I proposed (manually tracking the number of systems) since the type definition of a simstate says that a cell always exists:

cell (torch.Tensor): Unit cell vectors with shape (n_systems, 3, 3).

So we should be able to remove the torch.unique() by calling state.cell.shape[0]

I was actually surprised that a cell must always exist bc it might be inconvenient for bio systems. But a hack is to just make a super large cell

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Opened #621 for this.

JFYI on molecules: ASE gives a molecule a zero cell, and atoms_to_state carries it over as is, I think.

"""Perform one ASE-style FIRE optimization step."""
from torch_sim.optimizers import CellFireState

n_systems, device, dtype = state.n_systems, state.device, state.dtype

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

maybe we should have a proper fix for this by just manually tracking the number of systems in simstate. but this feels like it can easily lead to bugs

Comment thread tests/test_optimizers.py
)


def test_fire_ase_negative_power_branch(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

the parameter-reset and velocity-update checks in this test are now covered by test_fire_mixed_power_update_order

if isinstance(state, CellFireState):
system_power += (state.cell_forces * state.cell_velocities).sum(dim=(1, 2))
# Only update dt, alpha, and n_pos after the first step.
pos_mask_system &= ~is_first_step

@curtischong curtischong Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

this is how we get rid of the if statement. if it is the first step, pos_mask_system and neg_mask_system will be 0. so all the downstream variables that only trigger on the else part of the if statement will not be triggered

if isinstance(state, CellFireState):
state.cell_velocities.nan_to_num_(nan=0.0)

if nan_velocities.all():

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

this if statement is slow since we need to move the result of this GPU tensor to the CPU

(1.0 - alpha_atom) * state.velocities + alpha_atom * v_mixing_atom,
torch.zeros_like(state.velocities),
state.cell_velocities = torch.where(
~is_first_step, mixed_cell_velocities, state.cell_velocities

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

only apply the mixed_cell_velocities if we are NOT on the first step (this is us removing the if statement and having non-branching code)

Comment thread tests/test_optimizers.py
# Evolve 10 steps so system 0 has non-trivial FIRE state (dt, alpha, n_pos)
for _ in range(10):
state = ts.fire_step(state=state, model=lj_model)
state = ts.fire_step(state=state, model=lj_model, fire_flavor=fire_flavor)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

adding this increases test coverage for this PR so the codecov check doesn't fail us. previously, ts.fire_step only ran the ase_fire fire_flavor

Comment on lines +319 to +321
forces = state.forces
if isinstance(state, CellFireState):
forces = torch.where(is_first_step, forces, state.deform_grad_forces())

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

this is a bit weird to read but these three lines are the main part of the if/else statements we replaced in the previous code (ecluding the alpha_start_system declaration).

by default, the forces is just state.forces (seen in the if nan_velocities.all(): branch of the if statement, and in the else branch of the if isinstance(state, CellFireState) else state.forces if the first if statement failed.

we need to do the torch.where(is_first_step function which is like "the else branch triggering" in a non-branching way.

state.alpha[neg_mask_system] = alpha_start_system[neg_mask_system]
state.n_pos[neg_mask_system] = 0
state.dt = torch.where(neg_mask_system, state.dt * f_dec, state.dt)
state.alpha = torch.where(neg_mask_system, alpha_start, state.alpha)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

we got rid of alpha_start_system in this diff becuase we build the state.alpha tensor with this line:

state.alpha = torch.where(inc_mask, state.alpha * f_alpha, state.alpha)

and this line:

state.alpha = torch.where(neg_mask_system, alpha_start, state.alpha)
notice that this second line defaults to state.alpha when neg_mask_system is false

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

so we are properly replicating all the state.alpha[inc_mask] *= f_alpha and state.alpha[neg_mask_system] = alpha_start_system[neg_mask_system] in the previous code

@curtischong curtischong changed the title Remove GPU-CPU Synchronization in FIRE PERF: Remove GPU-CPU Synchronization in FIRE Sep 27, 2026

# Calculate power (newly zeroed systems will have power=0 → neg_mask)
system_power = tsm.batched_vdot(forces, state.velocities, state.system_idx)
if isinstance(state, CellFireState):

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

unfortunately the github diff from these lines onwards are hard to read. but they follow the original implementation quite closely

@curtischong
curtischong marked this pull request as ready for review September 27, 2026 15:29

@lil-lon lil-lon left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM. I reviewed the changes, and they preserve the existing logic (apart from the intended vv_fire cell-velocity NaN fix) while removing CPU/GPU syncs, including:

  • FIRE first-step detection
  • batched_vdot
  • updates of dt, alpha and n_pos

The way the number of systems is obtained can be improved as discussed in the thread above, but that can be done in a follow-up.
If @CompRhys is okay with this change, I think this PR is ready to merge.

@CompRhys

Copy link
Copy Markdown
Member

If @CompRhys is okay with this change, I think this PR is ready to merge.

Feel free to merge!

@lil-lon lil-lon left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM!

@lil-lon

lil-lon commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator

@CompRhys Thanks for checking! I don't have permission to merge into main, so could you merge this one?

@CompRhys
CompRhys enabled auto-merge (squash) October 22, 2026 10:07
@CompRhys
CompRhys merged commit 3cbb5dc into TorchSim:main Oct 22, 2026
64 checks passed
@curtischong
curtischong deleted the rm-cpu-transfers branch October 22, 2026 11:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants