Skip to content

Commit b3e32ce

Browse files
authored
fix reranker heap release after local scoring (#2530)
1 parent d0bbfa1 commit b3e32ce

2 files changed

Lines changed: 46 additions & 27 deletions

File tree

hindsight-api-slim/hindsight_api/engine/cross_encoder.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88

99
import asyncio
10+
import gc
1011
import logging
1112
import os
1213
import warnings
@@ -86,6 +87,12 @@ def _resolve_malloc_trim():
8687
_malloc_trim = _resolve_malloc_trim()
8788

8889

90+
def _release_rerank_heap() -> None:
91+
"""Release transient Python and native heap memory after local reranking."""
92+
gc.collect()
93+
_malloc_trim()
94+
95+
8996
class CrossEncoderModel(ABC):
9097
"""
9198
Abstract base class for cross-encoder reranking.
@@ -315,7 +322,7 @@ def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
315322
scores = self._model.predict(pairs, batch_size=self.batch_size, show_progress_bar=False)
316323
return scores.tolist() if hasattr(scores, "tolist") else list(scores)
317324
finally:
318-
_malloc_trim()
325+
_release_rerank_heap()
319326

320327
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
321328
"""
@@ -990,11 +997,11 @@ async def initialize(self) -> None:
990997

991998
def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
992999
"""Synchronous predict - processes each query group."""
993-
from flashrank import RerankRequest
994-
9951000
if not pairs:
9961001
return []
9971002

1003+
from flashrank import RerankRequest
1004+
9981005
try:
9991006
# Group pairs by query
10001007
query_groups: dict[str, list[tuple[int, str]]] = {}
@@ -1023,7 +1030,7 @@ def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
10231030

10241031
return all_scores
10251032
finally:
1026-
_malloc_trim()
1033+
_release_rerank_heap()
10271034

10281035
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
10291036
"""

hindsight-api-slim/tests/test_local_cross_encoder.py

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from hindsight_api.engine.cross_encoder import (
1616
FlashRankCrossEncoder,
1717
LocalSTCrossEncoder,
18+
_release_rerank_heap,
1819
_resolve_malloc_trim,
1920
)
2021

@@ -42,6 +43,17 @@ def test_module_level_trim_is_resolved(self):
4243
"""The module caches the resolved callable at import time."""
4344
assert callable(ce_module._malloc_trim)
4445

46+
def test_release_rerank_heap_collects_then_trims(self):
47+
"""Local reranker cleanup should free Python objects before trimming glibc."""
48+
calls = []
49+
with (
50+
patch.object(ce_module.gc, "collect", lambda: calls.append("gc")),
51+
patch.object(ce_module, "_malloc_trim", lambda: calls.append("trim")),
52+
):
53+
_release_rerank_heap()
54+
55+
assert calls == ["gc", "trim"]
56+
4557

4658
class TestLocalSTCrossEncoder:
4759
"""Unit tests for the SentenceTransformers-backed local reranker."""
@@ -126,28 +138,28 @@ async def test_predict_not_initialized_raises(self):
126138
with pytest.raises(RuntimeError, match="not initialized"):
127139
await encoder.predict([("q", "d")])
128140

129-
async def test_predict_calls_malloc_trim_after_success(self):
130-
"""The trim callable must run after every successful predict batch."""
141+
async def test_predict_releases_rerank_heap_after_success(self):
142+
"""The cleanup hook must run after every successful predict batch."""
131143
encoder = self._make_encoder()
132144
encoder._model.predict.return_value = [0.5]
133145

134-
trim_calls = []
135-
with patch.object(ce_module, "_malloc_trim", lambda: trim_calls.append("trim")):
146+
cleanup_calls = []
147+
with patch.object(ce_module, "_release_rerank_heap", lambda: cleanup_calls.append("cleanup")):
136148
await encoder.predict([("q", "doc")])
137149

138-
assert trim_calls == ["trim"]
150+
assert cleanup_calls == ["cleanup"]
139151

140-
async def test_predict_calls_malloc_trim_even_on_exception(self):
141-
"""`finally` semantics: trim must run when the model raises mid-batch."""
152+
async def test_predict_releases_rerank_heap_even_on_exception(self):
153+
"""`finally` semantics: cleanup must run when the model raises mid-batch."""
142154
encoder = self._make_encoder()
143155
encoder._model.predict.side_effect = RuntimeError("boom")
144156

145-
trim_calls = []
146-
with patch.object(ce_module, "_malloc_trim", lambda: trim_calls.append("trim")):
157+
cleanup_calls = []
158+
with patch.object(ce_module, "_release_rerank_heap", lambda: cleanup_calls.append("cleanup")):
147159
with pytest.raises(RuntimeError, match="boom"):
148160
await encoder.predict([("q", "doc")])
149161

150-
assert trim_calls == ["trim"]
162+
assert cleanup_calls == ["cleanup"]
151163

152164

153165
class TestFlashRankCrossEncoder:
@@ -214,43 +226,43 @@ def fake_rerank(request):
214226
# Two unique queries -> two rerank calls.
215227
assert encoder._ranker.rerank.call_count == 2
216228

217-
def test_predict_sync_calls_malloc_trim_after_success(self):
229+
def test_predict_sync_releases_rerank_heap_after_success(self):
218230
encoder = self._make_encoder()
219231
encoder._ranker.rerank.return_value = [{"id": 0, "score": 0.5}]
220232

221233
fake_flashrank = MagicMock()
222234
fake_flashrank.RerankRequest = lambda query, passages: MagicMock()
223235

224-
trim_calls = []
236+
cleanup_calls = []
225237
with patch.dict("sys.modules", {"flashrank": fake_flashrank}):
226-
with patch.object(ce_module, "_malloc_trim", lambda: trim_calls.append("trim")):
238+
with patch.object(ce_module, "_release_rerank_heap", lambda: cleanup_calls.append("cleanup")):
227239
encoder._predict_sync([("q", "doc")])
228240

229-
assert trim_calls == ["trim"]
241+
assert cleanup_calls == ["cleanup"]
230242

231-
def test_predict_sync_calls_malloc_trim_even_on_exception(self):
243+
def test_predict_sync_releases_rerank_heap_even_on_exception(self):
232244
encoder = self._make_encoder()
233245
encoder._ranker.rerank.side_effect = RuntimeError("flashrank boom")
234246

235247
fake_flashrank = MagicMock()
236248
fake_flashrank.RerankRequest = lambda query, passages: MagicMock()
237249

238-
trim_calls = []
250+
cleanup_calls = []
239251
with patch.dict("sys.modules", {"flashrank": fake_flashrank}):
240-
with patch.object(ce_module, "_malloc_trim", lambda: trim_calls.append("trim")):
252+
with patch.object(ce_module, "_release_rerank_heap", lambda: cleanup_calls.append("cleanup")):
241253
with pytest.raises(RuntimeError, match="flashrank boom"):
242254
encoder._predict_sync([("q", "doc")])
243255

244-
assert trim_calls == ["trim"]
256+
assert cleanup_calls == ["cleanup"]
245257

246-
def test_predict_sync_empty_pairs_does_not_trim(self):
258+
def test_predict_sync_empty_pairs_does_not_release_heap(self):
247259
"""The early `if not pairs: return []` short-circuits before the
248-
try/finally, so trim doesn't fire on a no-op call. This is intentional
260+
try/finally, so cleanup doesn't fire on a no-op call. This is intentional
249261
— nothing was allocated."""
250262
encoder = self._make_encoder()
251263

252-
trim_calls = []
253-
with patch.object(ce_module, "_malloc_trim", lambda: trim_calls.append("trim")):
264+
cleanup_calls = []
265+
with patch.object(ce_module, "_release_rerank_heap", lambda: cleanup_calls.append("cleanup")):
254266
encoder._predict_sync([])
255267

256-
assert trim_calls == []
268+
assert cleanup_calls == []

0 commit comments

Comments
 (0)