Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 121 additions & 21 deletions mellea/backends/huggingface.py
Original file line number Diff line number Diff line change
Expand Up @@ -1525,12 +1525,25 @@ class used during generation, if any.
self.cache_put(cache_key, cache_info)

# Clear KV cache and scores from HF output; retained via LRU cache above.
hf_output.past_key_values = None
hf_output.scores = None
# `ModelOutput` (`OrderedDict` subclass) does not sync `None` writes back
# to the mapping, so plain attribute assignment leaves the dict entry — and
# its tensor — alive.
for _field in ("past_key_values", "scores"):
if _field in hf_output:
dict.__delitem__(hf_output, _field)
try:
object.__delattr__(hf_output, _field)
except AttributeError:
pass

# Clear the raw logits tensor (scores already cleared above if cached).
if isinstance(hf_output, GenerateDecoderOnlyOutput):
hf_output.logits = None
if "logits" in hf_output:
dict.__delitem__(hf_output, "logits")
try:
object.__delattr__(hf_output, "logits")
except AttributeError:
pass

# Only scan for tools if we are not doing structured output and tool calls were provided to the model.
if _format is None and tool_calls:
Expand Down Expand Up @@ -1608,20 +1621,26 @@ class used during generation, if any.
if not self._use_caches and isinstance(
mot.raw.response, GenerateDecoderOnlyOutput
):
import gc

hf_out = mot.raw.response
if hasattr(hf_out, "sequences") and hf_out.sequences is not None:
del hf_out.sequences
if hasattr(hf_out, "scores") and hf_out.scores is not None:
del hf_out.scores
if hasattr(hf_out, "logits") and hf_out.logits is not None:
del hf_out.logits
# GenerateDecoderOnlyOutput is a ModelOutput (OrderedDict subclass).
# ModelOutput.__setattr__ skips the dict write when value is None, and
# ModelOutput defines no __delattr__, so both `out.f = None` and
# `del out.f` leave the mapping entry — and its tensor — alive.
# Clear both the dict entry and the instance attribute to release them.
for field in ("sequences", "scores", "logits"):

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.

One thing this list lets through: on the intrinsic path with caching off, the captured output (the raw_hf_output_cell at :752) is still reachable through mot._gen.process (the partial at :828) even after mot.raw.response = None below — and this list doesn't touch past_key_values. So with logits requested, the KV cache can still linger on the held MOT. Not a regression — before this PR the whole output was pinned there, since the old del never reached the mapping — but "Fixes #1549" closes the issue on merge with this corner still open and untracked. Either fix it here (add past_key_values to the list and drop the cell reference, with a weakref test that holds the MOT) or open a tracking issue.

if field in hf_out:
dict.__delitem__(hf_out, field)
try:
object.__delattr__(hf_out, field)
except AttributeError:
pass
mot.raw.response = None

# Force Python GC and return CUDA memory to device
gc.collect()
torch.cuda.empty_cache()
if torch.cuda.is_available():
import gc

gc.collect()
torch.cuda.empty_cache()

# Generate the log for this ModelOutputThunk.
generate_log = GenerateLog()
Expand Down Expand Up @@ -1766,16 +1785,75 @@ async def _generate_from_raw(
result.generation.provider = self._provider
result.raw.provider = self._provider

if want_logits and outputs.scores is not None:
# Clone each slice so this MOT does not hold a view into the shared batch allocation.
result.generation.logits = tuple(
step_scores[i].detach().clone() for step_scores in outputs.scores
# Extract per-MOT tensors once. These are shared by generation
# and, where supported, raw.response.
mot_scores: tuple[torch.Tensor, ...] | None = None
if outputs.scores is not None:
mot_scores = tuple(
score[i].detach().clone() for score in outputs.scores
)

mot_logits_raw: tuple[torch.Tensor, ...] | None = None
if outputs.logits is not None:
mot_logits_raw = tuple(
logits[i].detach().clone() for logits in outputs.logits
)

# Construct a per-MOT GenerateDecoderOnlyOutput slice holding clones of row i.
# past_key_values, attentions, and hidden_states are shared across the batch
# and cannot be sliced per MOT, so they are omitted.
# Note: beam-search outputs (GenerateBeamDecoderOnlyOutput) are not supported here;
# mot.raw.response will be None.
if (
self._use_caches
and isinstance(outputs, GenerateDecoderOnlyOutput)
and isinstance(outputs.sequences, torch.Tensor)
):
response_scores = (
tuple(score.unsqueeze(0) for score in mot_scores)
if mot_scores is not None
else None
)
response_logits = (
tuple(logits.unsqueeze(0) for logits in mot_logits_raw)
if mot_logits_raw is not None
else None
)

if "raw_batch_response_fields_omitted" not in self._warned_about:
self._warned_about.add("raw_batch_response_fields_omitted")
MelleaLogger.get_logger().debug(
"mot.raw.response.past_key_values, .attentions, and "
".hidden_states are not available on the raw batch path "
"and will always be None."
)

if want_raw_logits and outputs.logits is not None:
result.generation.raw_logits = tuple(
step_logits[i].detach().clone() for step_logits in outputs.logits
result.raw.response = GenerateDecoderOnlyOutput(
sequences=cast(
"torch.LongTensor",
outputs.sequences[i : i + 1, :].detach().clone(),
),
scores=cast("tuple[torch.FloatTensor] | None", response_scores),
logits=cast("tuple[torch.FloatTensor] | None", response_logits),
attentions=None,
hidden_states=None,
past_key_values=None,
)
elif self._use_caches:
# Beam search slicing is not feasible — raw.response is not populated.
warn_key = "raw_batch_beam_search_unsupported"
if warn_key not in self._warned_about:
self._warned_about.add(warn_key)
MelleaLogger.get_logger().debug(
"mot.raw.response is not available on the raw batch and will be None."
)

# Reuse the cloned tensors.
if want_logits and mot_scores is not None:
result.generation.logits = mot_scores

if want_raw_logits and mot_logits_raw is not None:
result.generation.raw_logits = mot_logits_raw

action = actions[i]
result.parsed_repr = (
Expand All @@ -1794,6 +1872,28 @@ async def _generate_from_raw(
result._generate_log = generate_log
results.append(result)

# Drop all references that might pin the shared batch tensors.
# `sequences_to_decode` holds slice views of `outputs.sequences`, and
# `outputs` is a `GenerateDecoderOnlyOutput` — a `ModelOutput`, which
# subclasses `OrderedDict`. `del obj.attr` only removes the `__dict__`
# slot; the `OrderedDict` entry keeps a strong reference to the tensor.
# Setting `outputs = None` drops the whole container at once
del sequences_to_decode

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.

One that predates the PR: if batch_decode, the clone loop or action.parse raises, this cleanup never runs and the traceback keeps outputs — the whole batch — alive for as long as the exception lives. The PR improves this path overall, so just noting it; a try/finally would close the gap.

outputs = None
if torch.cuda.is_available():
import gc

MelleaLogger.get_logger().debug(
"GPU memory before raw batch cleanup: %d bytes reserved",
torch.cuda.memory_reserved(),
)
gc.collect()
torch.cuda.empty_cache()
MelleaLogger.get_logger().debug(
"GPU memory after raw batch cleanup: %d bytes reserved",
torch.cuda.memory_reserved(),
)

usage: dict[str, Any] | None = (
{
"prompt_tokens": agg_prompt,
Expand Down
90 changes: 90 additions & 0 deletions test/backends/test_huggingface_raw_response_copy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Copyright IBM Corp. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0

"""Unit tests for copy/deepcopy semantics of ModelOutputThunks that hold an HF
GenerateDecoderOnlyOutput in raw.response — the raw batch path specific case."""

import copy
from typing import Any

import pytest

torch = pytest.importorskip("torch", reason="torch not installed — install mellea[hf]")
pytest.importorskip(
"transformers", reason="transformers not installed — install mellea[hf]"
)
pytest.importorskip(

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.

This file skips on llguidance, but nothing it imports needs it — an install without llguidance silently skips these five tests for no reason.

"llguidance", reason="llguidance not installed — install mellea[hf]"
)

from transformers.generation.utils import GenerateDecoderOnlyOutput

from mellea.core import ModelOutputThunk


def _make_mot_with_hf_raw_response() -> tuple[ModelOutputThunk, Any]:
"""Build a MOT whose raw.response is a CPU-only GenerateDecoderOnlyOutput with a view."""
full_batch = torch.arange(6, dtype=torch.long).reshape(2, 3)
# Simulate the view produced by the raw batch path for batch item 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.

This deliberately builds a view to test copy semantics (fine) — but the comment reads as though the raw batch path produces a view, and it clones now. A word or two so nobody chases a view that no longer exists.

row_view = full_batch[0:1, :]
hf_out = GenerateDecoderOnlyOutput(
sequences=row_view,
scores=None,
logits=None,
attentions=None,
hidden_states=None,
past_key_values=None,
)
mot = ModelOutputThunk(value="hello")
mot.raw.response = hf_out
return mot, full_batch


def test_shallow_copy_raw_response_is_same_object():
"""copy.copy(mot).raw.response is the same object as mot.raw.response."""
mot, _ = _make_mot_with_hf_raw_response()
copied = copy.copy(mot)
assert copied.raw.response is mot.raw.response, (
"shallow copy must keep raw.response as the same object"
)


def test_shallow_copy_raw_response_sequences_shares_storage():
"""Shallow-copied MOT preserves shared tensor storage for raw.response.sequences."""
mot, full_batch = _make_mot_with_hf_raw_response()
copied = copy.copy(mot)
assert (
copied.raw.response.sequences.untyped_storage().data_ptr()
== full_batch.untyped_storage().data_ptr()
), (
"shallow copy: raw.response.sequences must still share storage with the original batch"
)


def test_deepcopy_raw_response_is_distinct_object():
"""copy.deepcopy(mot).raw.response is a distinct object from mot.raw.response."""
mot, _ = _make_mot_with_hf_raw_response()
deep = copy.deepcopy(mot)
assert deep.raw.response is not mot.raw.response, (
"deepcopy must produce a new raw.response object"
)


def test_deepcopy_raw_response_sequences_does_not_share_storage():
"""Deepcopy breaks tensor storage sharing for raw.response.sequences."""
mot, full_batch = _make_mot_with_hf_raw_response()
deep = copy.deepcopy(mot)
assert (
deep.raw.response.sequences.untyped_storage().data_ptr()
!= full_batch.untyped_storage().data_ptr()
), "deepcopy: raw.response.sequences must NOT share storage with the original"


def test_deepcopy_raw_response_sequences_preserves_values():
"""Deepcopy preserves tensor values in raw.response.sequences despite storage isolation."""
mot, _ = _make_mot_with_hf_raw_response()
original_values = mot.raw.response.sequences.clone()
deep = copy.deepcopy(mot)
assert torch.equal(deep.raw.response.sequences, original_values), (
"deepcopy must preserve tensor values in raw.response.sequences"
)
Loading
Loading