docs(distorch): document GC anchor solution for selective unload

Add comprehensive documentation for the DisTorch selective model unload
solution that addresses Python garbage collection issues. The document
details:

- Problem: Models with keep_loaded=False were being prematurely garbage
  collected despite being added to current_loaded_models list
- Root cause: Reassigning current_loaded_models created the only strong
  reference, making models vulnerable to GC between assignment and next
  access
- Solution: Global GC anchor set (_MGPU_RETENTION_ANCHORS) maintains
  strong references to ModelPatcher objects that should survive cleanup
- Implementation: Early delegation check, anchor protection during
  categorization, and explicit lifecycle management
- Testing results: Confirms Flux unloads while VAE/CLIP remain protected

This solution ensures DisTorch models can selectively unload while
keeping VAE and CLIP models loaded, preventing memory management race
conditions with Python's garbage collector.
This commit is contained in:
John Pollock
2025-09-29 15:45:46 -05:00
parent c23dc083d3
commit bde51c6236
@@ -0,0 +1,209 @@
# DisTorch Selective Unload Solution
**Date:** 2025-09-29
**Commit Proven:** ae8bb7cf (detached HEAD)
**Status:** Working solution identified and tested
## Problem Statement
DisTorch models with `keep_loaded=False` (or `_mgpu_unload_distorch_model=True` in HEAD) should unload, while VAE/CLIP models should remain. The categorization logic works correctly, but models disappear anyway before they can survive the cleanup cycle.
### Observed Behavior (Broken)
```
[SELECTIVE_COMPLETE] Updated mm.current_loaded_models, new count: 2
[REMAINING_MODEL] 0: AutoencodingEngine
[REMAINING_MODEL] 1: FluxClipModel_
[patched_soft_empty_start]
[DETECT_DEBUG] loaded models: 0 ← GONE!
```
Models correctly placed in `mm.current_loaded_models` list but cleared before next phase.
## Root Cause: Python Garbage Collection
**The Issue:** Reassigning `mm.current_loaded_models` creates the ONLY strong reference to kept models. Between the assignment and the next access, Python's garbage collector can run and clear them because:
1. Original references in execution cache may be weak or cleared
2. Clone patchers have no strong references after parent is GC'd
3. `mm.current_loaded_models` list is the sole remaining strong reference
4. Something triggers GC (cache clearing, memory pressure, etc.)
5. Models disappear despite being in the list
## Solution: GC Anchor Protection
**Mechanism:** Maintain a global set that holds strong references to ModelPatcher objects that should survive garbage collection.
```python
# Global anchor set - prevents GC from clearing these objects
_MGPU_RETENTION_ANCHORS = set()
def add_retention_anchor(model_patcher, reason="keep_loaded"):
"""Add strong reference to prevent GC"""
if model_patcher is not None:
_MGPU_RETENTION_ANCHORS.add(model_patcher)
def clear_all_retention_anchors(reason="manual_clear"):
"""Remove all anchors to allow normal cleanup"""
_MGPU_RETENTION_ANCHORS.clear()
```
### Why This Works
1. **Global Scope:** Set lives at module level, immune to local cleanup
2. **Strong References:** `set.add(object)` creates strong reference preventing GC
3. **Explicit Lifecycle:** We control exactly when protection starts and ends
4. **No Side Effects:** Anchors don't affect ComfyUI's normal model management
5. **Reversible:** Clearing anchors restores normal behavior immediately
## The Complete Solution (ae8bb7cf)
### 1. Early Delegation Check
```python
# Check if there are any DisTorch models that want to be unloaded
has_distorch_to_unload = any(
hasattr(lm.model.model, '_mgpu_keep_loaded') and
not lm.model.model._mgpu_keep_loaded
for lm in mm.current_loaded_models
if lm.model is not None and hasattr(lm.model, 'model')
)
if not has_distorch_to_unload:
# No selective unload needed - clear anchors and delegate
clear_all_retention_anchors(reason="no_selective_unload_needed")
_mgpu_original_unload_all_models()
return
```
**Why This Matters:** Without this check, non-DisTorch models (VAE/CLIP after DisTorch unloaded) would be retained forever because they pass the `should_retain` test.
### 2. Anchor Protection During Categorization
```python
if should_retain:
kept_models.append(lm)
# Protect from GC during cleanup cycle
add_retention_anchor(mp, "keep_loaded_protection")
else:
models_to_unload.append(lm)
```
**Why This Matters:** Creates strong reference the moment we decide to keep a model, before any GC opportunity.
### 3. Reassign List (Existing Logic)
```python
mm.current_loaded_models = kept_models
```
**Why This Works Now:** GC anchors ensure models survive until next cleanup cycle.
## Tested Behavior (Working)
### First Cleanup (After DisTorch Workflow)
```
[UNLOAD_DEBUG] Flux, keep_loaded=False ← DisTorch model wants unload
[UNLOAD_DEBUG] AutoencodingEngine, keep_loaded=False ← Standard VAE
[UNLOAD_DEBUG] Adding to kept_models: AutoencodingEngine
[GC_ANCHOR] Added retention anchor for AutoencodingEngine, total anchors: 1
[UNLOAD_DEBUG] Adding to kept_models: FluxClipModel_
[GC_ANCHOR] Added retention anchor for FluxClipModel_, total anchors: 2
[UNLOAD_DEBUG] Final counts - kept_models: 2, models_to_unload: 1
Successfully retained 2 model(s) during unload
```
**Result:** Flux unloaded, VAE + CLIP protected and survive.
### Second Cleanup (Manager Button)
```
[UNLOAD_DEBUG] Patched unload_all_models called - initial model count: 2
No DisTorch models requesting unload - clearing anchors and delegating
[GC_ANCHOR] Cleared all 2 retention anchors, reason: no_selective_unload_needed
```
**Result:** Anchors cleared, original unload runs, all models properly unloaded (count goes to 0).
## What HEAD Already Has
HEAD (commit 01df0826) has:
1. ✅ Flag system (`_mgpu_unload_distorch_model` on inner model)
2. ✅ Categorization logic (selective_complete scan)
3. ✅ List reassignment (`mm.current_loaded_models = kept_models`)
4. ✅ Unload execution for flagged models
**HEAD is 95% complete.** It just lacks GC protection.
## What HEAD Needs (Minimal Additions)
### 1. GC Anchor Infrastructure (3 functions)
```python
_MGPU_RETENTION_ANCHORS = set()
def add_retention_anchor(model_patcher, reason="keep_loaded"):
if model_patcher is not None:
_MGPU_RETENTION_ANCHORS.add(model_patcher)
logger.mgpu_mm_log(f"[GC_ANCHOR] Added anchor for {type(model_patcher.model).__name__}, reason={reason}, total={len(_MGPU_RETENTION_ANCHORS)}")
def clear_all_retention_anchors(reason="manual_clear"):
count = len(_MGPU_RETENTION_ANCHORS)
_MGPU_RETENTION_ANCHORS.clear()
logger.mgpu_mm_log(f"[GC_ANCHOR] Cleared {count} anchors, reason={reason}")
```
### 2. Early Delegation Check (Before Categorization)
```python
# Check if any DisTorch models want unload
has_distorch_to_unload = any(
hasattr(lm.model.model, '_mgpu_unload_distorch_model') and
lm.model.model._mgpu_unload_distorch_model
for lm in mm.current_loaded_models
if lm.model is not None and hasattr(lm.model, 'model')
)
if not has_distorch_to_unload:
clear_all_retention_anchors(reason="no_selective_unload_needed")
_mgpu_original_unload_all_models()
return
```
### 3. Anchor Protection Call (During Categorization)
```python
if should_retain:
kept_models.append(lm)
add_retention_anchor(mp, "keep_loaded_protection") # ← Add this line
```
## Summary
**The fix is embarrassingly simple:** Add 3 utility functions and 2 function calls. The GC anchor system provides the strong references needed to keep models alive during the cleanup cycle, then explicitly clears them when selective unload is no longer needed.
**Key Insight:** Categorization logic was always correct. The problem was Python's garbage collector running between list reassignment and next access. GC anchors prevent this by maintaining global strong references with explicit lifecycle management.
## Technical Notes
- **Anchors are NOT a workaround:** This is proper reference management for objects that must survive multiple cleanup phases
- **No memory leaks:** Anchors cleared explicitly when no longer needed, allowing normal GC
- **Zero overhead:** Empty set when no DisTorch models active
- **Self-contained:** Protection automatically enabled/disabled based on model state
- **Compatible:** Works with ComfyUI's existing model management, no API changes
## Implementation Checklist for HEAD
- [ ] Add `_MGPU_RETENTION_ANCHORS` global set to model_management_mgpu.py
- [ ] Add `add_retention_anchor()` function
- [ ] Add `clear_all_retention_anchors()` function
- [ ] Add early delegation check before categorization loop
- [ ] Add `add_retention_anchor(mp, "keep_loaded_protection")` call in retention branch
- [ ] Test with DisTorch2 workflow: Flux should unload, VAE/CLIP should remain
- [ ] Test second cleanup: All models should unload completely
- [ ] Verify VRAM properly freed after second cleanup
## Why This Solution is Correct
The solution addresses the ACTUAL problem (GC clearing references) rather than symptoms. It's:
1. **Minimal:** 3 functions, 2 calls
2. **Explicit:** Clear lifecycle management
3. **Testable:** Easy to verify with logging
4. **Reversible:** Cleanup works normally after anchors cleared
5. **Safe:** No race conditions or edge cases
The user was correct: HEAD had everything except GC protection. This completes the puzzle.