Skip to content

Commit c8a696e

Browse files
romanlutzCopilot
andauthored
FIX: Prevent GCG model gradient accumulation (#2244)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5c847c5a-6917-4e2b-81a6-e511d9cbbe6e
1 parent bad686a commit c8a696e

3 files changed

Lines changed: 277 additions & 43 deletions

File tree

pyrit/executor/promptgen/gcg/attack/base/attack_manager.py

Lines changed: 72 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33

44
from __future__ import annotations
55

6-
import gc
76
import json
87
import logging
98
import math
109
import random
1110
import time
1211
from copy import deepcopy
12+
from dataclasses import dataclass
13+
from enum import Enum
1314
from typing import TYPE_CHECKING, Any, cast
1415

1516
import numpy as np
@@ -383,12 +384,10 @@ def logits(self, model: Any, test_controls: Any = None, return_ids: bool = False
383384

384385
if return_ids:
385386
del locs, test_ids
386-
gc.collect()
387387
return model(input_ids=ids, attention_mask=attn_mask).logits, ids
388388
del locs, test_ids
389389
logits = model(input_ids=ids, attention_mask=attn_mask).logits
390390
del ids
391-
gc.collect()
392391
return logits
393392

394393
def target_loss(self, logits: torch.Tensor, ids: torch.Tensor) -> torch.Tensor:
@@ -598,7 +597,14 @@ def grad(self, model: Any) -> torch.Tensor:
598597
Returns:
599598
torch.Tensor: Aggregated prompt gradients.
600599
"""
601-
return torch.stack([prompt.grad(model) for prompt in self._prompts]).sum(dim=0)
600+
first_gradient = self._prompts[0].grad(model)
601+
if len(self._prompts) == 1:
602+
return first_gradient
603+
result_dtype = first_gradient.dtype
604+
gradient = first_gradient.float() if result_dtype in (torch.float16, torch.bfloat16) else first_gradient.clone()
605+
for prompt in self._prompts[1:]:
606+
gradient.add_(prompt.grad(model).to(dtype=gradient.dtype))
607+
return gradient.to(dtype=result_dtype)
602608

603609
def logits(self, model: Any, test_controls: Any = None, return_ids: bool = False) -> Any:
604610
"""
@@ -888,7 +894,6 @@ def control_weight_fn(_: int) -> float:
888894

889895
steps += 1
890896
start = time.time()
891-
torch.cuda.empty_cache()
892897
control, loss = self.step(
893898
batch_size=batch_size,
894899
topk=topk,
@@ -940,14 +945,14 @@ def test(
940945
Jailbreak, exact-match, and loss results.
941946
"""
942947
for j, worker in enumerate(workers):
943-
worker(prompts[j], "test", worker.model)
948+
worker(prompts[j], ModelWorkerOperation.TEST)
944949
model_tests = np.array([worker.results.get() for worker in workers])
945950
model_tests_jb = model_tests[..., 0].tolist()
946951
model_tests_mb = model_tests[..., 1].tolist()
947952
model_tests_loss: list[list[float]] = []
948953
if include_loss:
949954
for j, worker in enumerate(workers):
950-
worker(prompts[j], "test_loss", worker.model)
955+
worker(prompts[j], ModelWorkerOperation.TEST_LOSS)
951956
model_tests_loss = [worker.results.get() for worker in workers]
952957

953958
return model_tests_jb, model_tests_mb, model_tests_loss
@@ -1781,6 +1786,33 @@ def run(
17811786
return total_jb, total_em, test_total_jb, test_total_em, total_outputs, test_total_outputs
17821787

17831788

1789+
class ModelWorkerOperation(str, Enum):
1790+
"""A model operation supported by ``ModelWorker``."""
1791+
1792+
GRAD = "grad"
1793+
LOGITS = "logits"
1794+
CONTRAST_LOGITS = "contrast_logits"
1795+
TEST = "test"
1796+
TEST_LOSS = "test_loss"
1797+
1798+
1799+
@dataclass(frozen=True)
1800+
class ModelWorkerTask:
1801+
"""
1802+
A spawn-safe worker payload that excludes the worker-owned model.
1803+
1804+
Typed model operations receive the worker's persistent model during dispatch,
1805+
so each queued task serializes only the prompt payload and operation arguments
1806+
rather than serializing the full model again. ``obj`` is the prompt or prompt
1807+
manager that receives the operation.
1808+
"""
1809+
1810+
obj: Any
1811+
operation: ModelWorkerOperation | Callable[..., Any]
1812+
args: tuple[Any, ...]
1813+
kwargs: dict[str, Any]
1814+
1815+
17841816
class ModelWorker:
17851817
"""Run model operations in a dedicated multiprocessing worker."""
17861818

@@ -1802,33 +1834,30 @@ def __init__(
18021834
move_to_device = cast("Callable[[torch.device], PreTrainedModel]", model.to)
18031835
self.model = move_to_device(torch.device(device)).eval()
18041836
self.tokenizer = tokenizer
1805-
self.tasks: mp.JoinableQueue[Any] = mp.JoinableQueue()
1837+
self.tasks: mp.JoinableQueue[ModelWorkerTask | None] = mp.JoinableQueue()
18061838
self.results: mp.JoinableQueue[Any] = mp.JoinableQueue()
18071839
self.process: mp.Process | None = None
18081840

18091841
@staticmethod
1810-
def run(model: Any, tasks: mp.JoinableQueue[Any], results: mp.JoinableQueue[Any]) -> None:
1842+
def run(
1843+
model: Any,
1844+
tasks: mp.JoinableQueue[ModelWorkerTask | None],
1845+
results: mp.JoinableQueue[Any],
1846+
) -> None:
18111847
"""Process queued model operations until a stop sentinel arrives."""
1848+
model.requires_grad_(False)
1849+
model.zero_grad(set_to_none=True)
18121850
while True:
18131851
task = tasks.get()
18141852
if task is None:
1853+
tasks.task_done()
18151854
break
1816-
ob, fn, args, kwargs = task
1817-
if fn == "grad":
1855+
if task.operation is ModelWorkerOperation.GRAD:
18181856
with torch.enable_grad(): # type: ignore[no-untyped-call, unused-ignore]
1819-
results.put(ob.grad(*args, **kwargs))
1857+
results.put(ModelWorker._execute_task(model=model, task=task))
18201858
else:
18211859
with torch.no_grad():
1822-
if fn == "logits":
1823-
results.put(ob.logits(*args, **kwargs))
1824-
elif fn == "contrast_logits":
1825-
results.put(ob.contrast_logits(*args, **kwargs))
1826-
elif fn == "test":
1827-
results.put(ob.test(*args, **kwargs))
1828-
elif fn == "test_loss":
1829-
results.put(ob.test_loss(*args, **kwargs))
1830-
else:
1831-
results.put(fn(*args, **kwargs))
1860+
results.put(ModelWorker._execute_task(model=model, task=task))
18321861
tasks.task_done()
18331862

18341863
def start(self) -> ModelWorker:
@@ -1856,16 +1885,35 @@ def stop(self) -> ModelWorker:
18561885
torch.cuda.empty_cache()
18571886
return self
18581887

1859-
def __call__(self, ob: Any, fn: str, *args: Any, **kwargs: Any) -> ModelWorker:
1888+
def __call__(
1889+
self,
1890+
ob: Any,
1891+
operation: ModelWorkerOperation | Callable[..., Any],
1892+
*args: Any,
1893+
**kwargs: Any,
1894+
) -> ModelWorker:
18601895
"""
18611896
Queue an operation for execution by this worker.
18621897
18631898
Returns:
18641899
ModelWorker: This worker.
18651900
"""
1866-
self.tasks.put((deepcopy(ob), fn, args, kwargs))
1901+
self.tasks.put(ModelWorkerTask(obj=deepcopy(ob), operation=operation, args=args, kwargs=kwargs))
18671902
return self
18681903

1904+
@staticmethod
1905+
def _execute_task(*, model: Any, task: ModelWorkerTask) -> Any:
1906+
"""
1907+
Execute a task with the persistent model when the operation requires one.
1908+
1909+
Returns:
1910+
Any: The operation result.
1911+
"""
1912+
if isinstance(task.operation, ModelWorkerOperation):
1913+
method = getattr(task.obj, task.operation.value)
1914+
return method(model, *task.args, **task.kwargs)
1915+
return task.operation(*task.args, **task.kwargs)
1916+
18691917

18701918
def get_workers(params: Any, evaluation: bool = False) -> tuple[list[ModelWorker], list[ModelWorker]]:
18711919
"""

pyrit/executor/promptgen/gcg/attack/gcg/gcg_attack.py

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT license.
33

4-
import gc
54
import logging
65
from typing import Any
76

@@ -12,6 +11,7 @@
1211

1312
from pyrit.executor.promptgen.gcg.attack.base.attack_manager import (
1413
AttackPrompt,
14+
ModelWorkerOperation,
1515
MultiPromptAttack,
1616
PromptManager,
1717
get_embedding_matrix,
@@ -48,7 +48,7 @@ def token_gradients(
4848
torch.Tensor: The gradients of each token in the input_slice with respect to the loss.
4949
5050
Raises:
51-
RuntimeError: If backpropagation does not produce token gradients.
51+
RuntimeError: If autograd does not produce token gradients.
5252
"""
5353
embed_weights = get_embedding_matrix(model)
5454
one_hot = torch.zeros(
@@ -70,11 +70,10 @@ def token_gradients(
7070
targets = input_ids[target_slice]
7171
loss = nn.CrossEntropyLoss()(logits[0, loss_slice, :], targets)
7272

73-
loss.backward()
74-
75-
if one_hot.grad is None:
76-
raise RuntimeError("Model backward pass did not produce token gradients")
77-
return one_hot.grad.clone()
73+
coordinate_gradient = torch.autograd.grad(loss, one_hot, allow_unused=True)[0]
74+
if coordinate_gradient is None:
75+
raise RuntimeError("Autograd did not produce token gradients")
76+
return coordinate_gradient
7877

7978

8079
class GCGAttackPrompt(AttackPrompt):
@@ -273,7 +272,7 @@ def step(
273272
loss_function = self._resolve_loss(target_weight=target_weight, control_weight=control_weight)
274273

275274
for j, worker in enumerate(self.workers):
276-
worker(self.prompts[j], "grad", worker.model)
275+
worker(self.prompts[j], ModelWorkerOperation.GRAD)
277276

278277
# Aggregate gradients
279278
grad = None
@@ -324,7 +323,6 @@ def step(
324323
)
325324
)
326325
del grad, control_cand
327-
gc.collect()
328326

329327
# Search
330328
loss = torch.zeros(len(control_cands) * batch_size).to(main_device)
@@ -336,7 +334,7 @@ def step(
336334
prompt_indices = progress if progress is not None else range(len(self.prompts[0]))
337335
for i in prompt_indices:
338336
for k, worker in enumerate(self.workers):
339-
worker(self.prompts[k][i], "logits", worker.model, cand, return_ids=True)
337+
worker(self.prompts[k][i], ModelWorkerOperation.LOGITS, cand, return_ids=True)
340338
logits, ids = zip(*[worker.results.get() for worker in self.workers], strict=True)
341339
loss[j * batch_size : (j + 1) * batch_size] += sum(
342340
loss_function.compute_loss(
@@ -348,7 +346,6 @@ def step(
348346
for k, (logit, token_ids) in enumerate(zip(logits, ids, strict=True))
349347
)
350348
del logits, ids
351-
gc.collect()
352349

353350
if progress is not None:
354351
progress.set_description(
@@ -359,9 +356,7 @@ def step(
359356
model_idx = min_idx // batch_size
360357
batch_idx = min_idx % batch_size
361358
next_control, cand_loss = control_cands[model_idx][batch_idx], loss[min_idx]
362-
363359
del control_cands, loss
364-
gc.collect()
365360

366361
current_length = self._get_control_length(control=next_control)
367362
if current_length is not None:

0 commit comments

Comments
 (0)