Tracing a silent-corruption bug in differentially private LoRA fine-tuning

immu49891 pts0 comments

The DP-LoRA silent corruption: how 5 months of broken fine-tuning hid in plain sight

Imran's Substack

SubscribeSign in

The DP-LoRA silent corruption: how 5 months of broken fine-tuning hid in plain sight<br>How a device-placement ordering quirk between opacus, PEFT, and HuggingFace caused DP fine-tuning to silently break, and what to check in your own setup.

Imran Ahamed<br>Jun 25, 2026

Share

In November 2025, a researcher ran a differentially private fine-tuning experiment on a sensitive language modeling task. Two model architectures, three privacy budgets, six total runs, roughly 45 GPU-hours of compute. Loss decreased steadily across all runs. The privacy budget accumulated as the optimizer ticked. Checkpoints saved without errors.<br>Every resulting model was unusable at inference. Not subtly off. Unusable.<br>Thanks for reading Imran's Substack! Subscribe for free to receive new posts and support my work.

Subscribe

The detection was almost accidental. The researcher noticed that utility collapsed in identical patterns across all three privacy budgets, which violated their expectation that lower ε should produce noisier and therefore less useful models. They ran a sanity check comparing saved adapter weights against their initialization values. The weights had not moved.<br>What followed was a community investigation across five months, three independent reproduction environments, and one false root cause, before settling on a device-placement ordering quirk that was technically a known opacus limitation but had never been written down where users would find it.<br>This is the story of that bug, and what I think every DP fine-tuning workflow should check before declaring training “complete.”<br>What DP-LoRA is supposed to do

Two ingredients here, both worth a paragraph.<br>Differential privacy in deep learning typically takes the DP-SGD form. Each per-sample gradient is clipped to a fixed L2 norm, then summed across the batch, then Gaussian noise is added to the sum before the optimizer step. The noise is calibrated to give a formal (ε, δ)-DP guarantee under composition over training. The standard PyTorch implementation lives in opacus, the differential-privacy library maintained by Meta and the PyTorch Foundation.<br>LoRA (Low-Rank Adaptation, Hu et al. 2021 is a parameter-efficient fine-tuning method that freezes the base model and learns a low-rank update on top. Instead of training all 125M parameters of GPT-2-small, you train roughly 600K. It’s faster and cheaper, with much lower memory needs.<br>The combination is natural. Fewer trainable parameters means less surface area for the Gaussian noise to land on, so you can achieve a given ε with less utility hit. For sensitive training data (medical records, financial transactions, internal corporate documents), this combination is increasingly the default. Tools like opacus + huggingface’s peft library make the integration roughly five lines of code.<br>Five lines that, as it turned out, could silently produce a model that never updated its weights.<br>The bug report

The original opacus issue #820 appeared on May 19, 2026. The reporter, GitHub username MN-Noor at the time (later renamed to MN-NR), provided a clean reproducer. About thirty lines of Python: load GPT-2 with PEFT 0.18.1, wrap it with opacus’s PrivacyEngine.make_private_with_epsilon, run one training step, check whether the LoRA weights moved.<br>With PEFT 0.18.1: Weight changed: False. Max delta: 0.00000000.<br>With PEFT 0.13.2 (from late 2024): Weight changed: True. Max delta: 0.00010004.<br>The reporter’s hypothesis was reasonable given what they had. PEFT 0.18.0 introduced changes to how LoRA parameters are organized. They reasoned that a parameter naming change (lora_A.weight to lora_A.default.weight) might be confusing opacus’s hook walker. Their suggested fix was to roll back to an older PEFT and pin the version in tutorials.<br>This is the diligent-engineer’s diagnosis. You bisected against a working version. You spotted a likely change. You proposed a forward path. Reasonable.<br>It also turned out to be wrong.<br>Reading the source instead of trusting the timeline

The first thing I did when I picked up the thread was check the PEFT source code at both versions the reporter mentioned. The relevant file is src/peft/tuners/lora/layer.py. The relevant lines define how LoRA adapter modules are constructed.<br>At PEFT v0.13.2, line 47:<br>self.lora_A = nn.ModuleDict({})

self.lora_B = nn.ModuleDict({})

At PEFT v0.18.0, line 115:<br>self.lora_A = nn.ModuleDict({})

self.lora_B = nn.ModuleDict({})

Identical. The “renamed parameters” theory was false at the source-code level. LoRA had been organized as ModuleDict[adapter_name] since at least 0.13.x, so the access path lora_A.default.weight was the same in both the broken and working versions.<br>That falsified the hypothesis, but it didn’t tell me what was actually wrong. Something else changed between 0.13.2 and 0.18.1 that broke opacus’s gradient flow. I needed a way to find it.<br>The...

peft lora opacus fine tuning check

Related Articles