Hoist from_array_interface wrapper class to module scope to avoid per-call reference cycles - #12344
Open
amitaypgy wants to merge 2 commits into
Open
Hoist from_array_interface wrapper class to module scope to avoid per-call reference cycles#12344amitaypgy wants to merge 2 commits into
amitaypgy wants to merge 2 commits into
Conversation
`from_array_interface` defined its `Array` wrapper class inside the function body, so a fresh class object was created on every call. Class objects are self-referential (via `__dict__`, `__mro__` and their property descriptors), so each one forms a reference cycle that reference counting alone cannot reclaim. Because `from_array_interface` runs on every prediction, this produced cyclic garbage on every request; long-running inference services that disable the cyclic GC (a common latency optimization) saw unbounded memory growth as these cycles -- and the native buffers they pin -- accumulated. Move the wrapper to module scope as `_ArrayInterfaceProxy` so it is created once at import. The per-call instance is not part of a cycle (it references the module-level class, which does not reference it back), so it is freed immediately by reference counting with no GC needed. Behavior is unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
amitaypgy
force-pushed
the
fix/from-array-interface-reference-cycle
branch
from
July 22, 2026 14:05
79cd57c to
18c287d
Compare
trivialfis
reviewed
Aug 3, 2026
Comment on lines
+124
to
+136
| """Wrapper type for communicating with numpy and cupy. | ||
|
|
||
| Defined at module scope rather than inside :py:func:`from_array_interface` on | ||
| purpose. A class defined in a function body is a brand-new object on every call | ||
| and participates in reference cycles (via ``__dict__``, ``__mro__`` and its | ||
| property descriptors), so it cannot be reclaimed by reference counting alone. | ||
| Since ``from_array_interface`` runs on every prediction, recreating the class | ||
| per call produces cyclic garbage that only the cyclic GC can free, leading to | ||
| unbounded memory growth in long-running services that disable the GC. Hoisting | ||
| the class here means it is created once at import; the per-call instance is not | ||
| part of a cycle and is freed immediately by reference counting. | ||
| """ | ||
|
|
Member
There was a problem hiding this comment.
Suggested change
| """Wrapper type for communicating with numpy and cupy. | |
| Defined at module scope rather than inside :py:func:`from_array_interface` on | |
| purpose. A class defined in a function body is a brand-new object on every call | |
| and participates in reference cycles (via ``__dict__``, ``__mro__`` and its | |
| property descriptors), so it cannot be reclaimed by reference counting alone. | |
| Since ``from_array_interface`` runs on every prediction, recreating the class | |
| per call produces cyclic garbage that only the cyclic GC can free, leading to | |
| unbounded memory growth in long-running services that disable the GC. Hoisting | |
| the class here means it is created once at import; the per-call instance is not | |
| part of a cycle and is freed immediately by reference counting. | |
| """ | |
| """Wrapper type for communicating with numpy and cupy.""" | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
from_array_interfacedefines itsArraywrapper class inside the function body, so a brand-new class object is created on every call. Class objects are self-referential and thus form reference cycles that plain reference counting cannot reclaim. Becausefrom_array_interfaceruns on every prediction (and on everyDMatrix/QuantileDMatrixconstruction), this leaks cyclic garbage on every request. Services that run with the cyclic GC disabled grow in memory without bound.This PR hoists the wrapper to module scope (
_ArrayInterfaceProxy) so it is created once at import. Behavior is byte-for-byte identical; only the class location changed.Root cause
from_array_interface(currently inpython-package/xgboost/_data_utils.py) looks like this:A
classstatement in a function body creates a new class object each time the function runs. Every class object is inherently self-referential:Array.__dict__holds thepropertydescriptors, and each descriptor'sfget/fsetare functions whose__globals__/closure and__qualname__tie back to the class namespace,Array.__mro__/__bases__reference the class,__dict__.These form reference cycles. CPython frees ordinary objects immediately via reference counting, but a cycle keeps every member's refcount ≥ 1, so a cycle can only be reclaimed by the cyclic garbage collector (
gc).from_array_interfaceis on the hottest path in the library — it is called byBooster.predict()(via_prediction_output) and by theDMatrixbuilders — so each call leaves a fresh, unreclaimable-by-refcount class cycle behind.Why this matters in production
Many latency-sensitive inference services call
gc.disable()to avoid unpredictable GC pauses on the request path. With the cyclic collector off, the per-call cycles created here are never collected. They accumulate for the life of the process, and because these wrapper objects sit on top of array buffers, the native memory they pin accumulates too — i.e. steady, unbounded RSS growth proportional to the number of predictions served.We hit exactly this in a long-running scoring service (Python 3.13 / numpy 2.x / xgboost 2.x) that scores one application at a time against dozens of boosters per request.
Reproduction
Standalone script (no XGBoost internals, just the public API). It fingerprints how many objects a single request creates that are only reclaimable by
gc.collect()(i.e. cyclic), and then runs a sustained loop with the GC disabled to show RSS growth.What you observe:
GC_ENABLED = False(matches a real service that disabled the GC):cyclic_onlyis a fixed, non-zero count for every request,objectsclimbs monotonically, andd_rssgrows steadily across iterations.GC_ENABLED = True(control): RSS stays flat because the cyclic collector keeps sweeping up the per-call class cycles — at the cost of the GC pauses services turn off in the first place.cyclic_onlydrops to ~0 for thefrom_array_interfacepath (the per-call instance is not in a cycle), so RSS stays flat even with the GC disabled.The fix
Move the wrapper class out of the function to module scope as
_ArrayInterfaceProxy, created once at import. The per-call_ArrayInterfaceProxy()instance is not part of a cycle — it references the module-level class, which does not reference the instance back — so it is freed immediately by reference counting, no GC required. The class body (array-interface / cuda-array-interface getters and setters,shape,size) is unchanged, so there is no behavioral or API change.Affects 2.x too — please also fix on the 2.x line
This is not new to 3.x. The same in-function
class Arrayhas existed since the 2.0 line (release_2.0.0/release_2.1.0), where the function lived inpython-package/xgboost/core.py; it was later moved (unchanged) to_data_utils.pyin 3.0. So every 2.x and 3.x release has this per-call cycle.We are currently pinned to xgboost 2.x, so it would be very helpful if this fix could also be backported to the maintained 2.x release branch (the change is a trivial, behavior-preserving hoist of the same class in
core.py). Happy to open a companion PR against the 2.x branch if that's preferred.