Skip to content

[Bug] low_cpu_mem_usage=True is not thread-safe and can leak the global nn.Module.register_parameter patch #14347

Description

@issue-ac

Describe the bug

[Bug] low_cpu_mem_usage=True is not thread-safe and can leak the global nn.Module.register_parameter patch

When low_cpu_mem_usage=True, adapter injection enters PEFT's
init_empty_weights() context for each target module
(tuners_utils.py).
The context replaces torch.nn.Module.register_parameter process-wide and
restores the value captured on entry
(integrations.py).

Actual behavior

  • An active init_empty_weights() context can lose its patch when another
    thread exits.
  • After both contexts exit, nn.Module.register_parameter can remain patched.
  • Unrelated modules subsequently created in the process receive meta
    parameters.
  • The process remains corrupted until the function is repaired or the process
    is restarted.

There is a second process-global value in the same implementation,
_init_on_device._skip, which may also allow cross-thread interference.

Downstream impact in Diffusers

Diffusers enables the PEFT low-memory LoRA path by default for compatible
versions
(default selection,
load_lora_weights() use).
With PEFT 0.20.0 and Diffusers 0.39.0,
_LOW_CPU_MEM_USAGE_DEFAULT_LORA evaluates to True.

In an end-to-end SDXL test with two independent pipelines in one process:

  • Sequential LoRA loads generated valid images.
  • The first pair of concurrent load_lora_weights() calls caused both
    pipelines' final latent tensors to become entirely NaN (finite_fraction=0).
  • Both images were black, and later sequential requests remained black.
  • Loading the adapters sequentially and then running inference concurrently did
    not fail.
  • Serializing only adapter load/set/unload operations did not fail.
  • Passing low_cpu_mem_usage=False did not fail: a full-resolution control run
    at 896x1152 and 28 steps completed 10 generations, including three concurrent
    pairs, with 0 black images, 0 errors, and finite latents throughout.

The end-to-end test used Diffusers 0.32.2, PEFT 0.20.0, PyTorch 2.13.0+cu130,
and an NVIDIA H100 NVL. The deterministic CPU reproducer above confirms the
same root defect on the latest Diffusers 0.39.0 / PEFT 0.20.0 stack, and the
relevant implementation is unchanged on both current main branches.

Expected behavior

Concurrent adapter injection on independent models should not corrupt global
PyTorch behavior. In particular:

  • the patch must remain effective until its owning context exits;
  • after all contexts exit, nn.Module.register_parameter must be restored to
    its original value; and
  • unrelated modules created afterward must use their requested device rather
    than silently receiving meta parameters.

If concurrent low-memory adapter injection cannot be supported, the operation
should be internally serialized or explicitly rejected/documented rather than
silently corrupting the process.

Workaround

For Diffusers, explicitly disabling the low-memory adapter initialization path
avoids this context manager:

pipe.load_lora_weights(
    lora_path,
    adapter_name=adapter_name,
    low_cpu_mem_usage=False,
)

Reproduction

Install the latest PEFT release:

python -m pip install "peft==0.20.0"

The following model-free reproducer forces two contexts to overlap and exit in
the opposite order:

import platform
import threading

import peft
import torch
from peft.utils.integrations import init_empty_weights


original = torch.nn.Module.register_parameter
a_entered = threading.Event()
b_entered = threading.Event()
a_exited = threading.Event()
observed = {}


def worker_a():
    with init_empty_weights():
        a_entered.set()
        assert b_entered.wait(5)
    a_exited.set()


def worker_b():
    assert a_entered.wait(5)
    with init_empty_weights():
        b_entered.set()
        assert a_exited.wait(5)
        observed["active_context_lost_patch"] = (
            torch.nn.Module.register_parameter is original
        )


a = threading.Thread(target=worker_a)
b = threading.Thread(target=worker_b)
a.start()
b.start()
a.join()
b.join()

try:
    observed["patch_leaked_after_both_contexts"] = (
        torch.nn.Module.register_parameter is not original
    )
    observed["new_parameter_device"] = torch.nn.Linear(2, 2).weight.device.type

    print(f"Python: {platform.python_version()}")
    print(f"PyTorch: {torch.__version__}")
    print(f"PEFT: {peft.__version__}")
    for key, value in observed.items():
        print(f"{key}: {value}")
finally:
    # Repair the process so the reproducer exits cleanly.
    torch.nn.Module.register_parameter = original

assert observed == {
    "active_context_lost_patch": True,
    "patch_leaked_after_both_contexts": True,
    "new_parameter_device": "meta",
}

Observed output:

Python: 3.12.3
PyTorch: 2.13.0+cpu
PEFT: 0.20.0
active_context_lost_patch: True
patch_leaked_after_both_contexts: True
new_parameter_device: meta

The failure follows directly from the interleaving:

  1. Thread A captures the original function and installs wrapper A.
  2. Thread B captures wrapper A and installs wrapper B.
  3. Thread A exits first and restores the original function, even though B's
    context is still active.
  4. Thread B exits and restores wrapper A, leaving PEFT's meta-device wrapper
    installed after every context has finished.

The case uses two
independent model/pipeline instances, but they collide through this process-wide
function replacement.

Logs

System Info

System Info

  • PEFT: 0.20.0 (latest PyPI release as of 2026-07-30)
  • Diffusers: 0.39.0 (latest PyPI release as of 2026-07-30)
  • Transformers: 5.14.1
  • Accelerate: 1.14.0
  • PyTorch: 2.13.0+cpu
  • Python: 3.12.3
  • Platform: Linux
  • Minimal reproduction requires no GPU or model download.
  • Current upstream source was also inspected:

Both current main branches still contain the behavior described below.

Who can help?

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions