Skip to content

Simplex offloading (2x speed)#1644

Open
dxqb wants to merge 10 commits into
Nerogar:masterfrom
dxqb:streaming-simplex-squashed
Open

Simplex offloading (2x speed)#1644
dxqb wants to merge 10 commits into
Nerogar:masterfrom
dxqb:streaming-simplex-squashed

Conversation

@dxqb

@dxqb dxqb commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Summary

Effectively doubles the offloading speed for LoRA training. If offloading is the bottleneck it can make a big difference in overall training speed, for example Qwen on my 16 GB card with 0.5 offloading:

Baseline: 1.7 s/it
This branch: 1.2 s/it

The current offloading method transfers the model to GPU and back, but single-duplex: transfers to GPU are not parallel to transfers from GPU.
A full-duplex mode would be the obvious optimization and that works, but:

  • it's only about 1.5x. PCIe doesn't seem to be full-speed full-duplex
  • it needs more vram because more layers are in-transit

This PR implements something else instead:
It only transfers from CPU and GPU, and discards after use. This is effectively 2x the speed of current offloading, with some limitations:

  • only for LoRA training - full finetuning needs to transfer the trained weights back to RAM
  • the full model has to be in RAM (but this is only a limitation compared to the streaming branch - in the main branch you needed the RAM to store the full model during loading and sampling anyway)

Simplex offloading is automatically enabled if "Cache in RAM" is enabled, offloading is enabled, and the streaming loader is enabled.

It's a small PR but builds on top of #1642 hence the large diff.
This PRs commit: 96ffea2

Test plan

  • pre-commit run --all-files passes
  • Launched the affected UI or script and exercised the change
  • Tested with at least one real preset / config when relevant (note which: Qwen)

AI assistance

  • AI-assisted — I have read every line in this diff and can defend each change

dxqb and others added 10 commits July 23, 2026 22:20
…()/evict() API

Replaces the per-model `{part}_to(device)` methods across all model classes,
plus scattered call sites in dataLoader/modelSetup/modelSampler/GenericTrainer,
with generic BaseModel methods driven by the existing ModelType.model_parts()
registry: materialize(*parts), evict(*parts), and materialize_only(*parts)
(evict everything else, then materialize the given parts - the swap-in/swap-out
pattern used throughout the Samplers and text-caching setup). eval() and
adapters() are likewise made concrete on BaseModel instead of hand-written per
model. Models whose component names diverge (Wuerstchen) or that have
components outside model_parts() (SD's depth_estimator, Anima's
text_conditioner) override the relevant methods directly.

Also fixes multi-TE samplers (Flux/SD3/SDXL/HiDream/HunyuanVideo) that
previously evicted all but the first text encoder and ran encode_text with
the rest still on temp_device.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extracts the transformer / text-encoder / vae loading logic duplicated
across the per-model loaders into shared helpers on HFModelLoaderMixin:

- _load_transformer: the from_single_file(..., quantization_config=
  GGUFQuantizationConfig(...)) / else-load-from-repo pattern for loading
  an optionally-GGUF-quantized transformer checkpoint, previously
  duplicated across 9 loaders.
- _load_text_encoder: thin wrapper over _load_transformers_sub_module
  giving every loader one call site for the load-on-demand streaming
  branch to hook.
- _load_vae: collapses the duplicated "separate vae repo overrides the
  base vae subfolder" branch across 15 diffusers-path loaders.

Flux's separate-transformer/pipeline path and HunyuanVideo's ckpt path
use structurally different else-branches and were left untouched, as
were the safetensors/pipeline-sourced vae branches.

Also fixes Ideogram's VAE Override being silently ignored (model_names.
vae_model was never read by the loader) and a HiDream text_encoder_4
override regression introduced while switching to _load_text_encoder
(the override repo holds text_encoder_4 at its root with no subfolder).

Pure refactor otherwise, no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On CUDA, the layer-offload cache now uses one cache tensor instead of
the multi-chunk split: a large cuda allocation is page-mapped, so one
buffer packs with no inter-chunk tail waste, and the arena is filled
per-layer from the CPU so no full resident source coexists with it.
The host/pinned cache keeps the lazy multi-chunk split (its
peak-doubling justification is host-only).

Each layer-offload cache tensor, and each BaseModel component move,
gets its own dedicated torch.cuda.MemPool, so the churny small
tensors in the default pool can't wedge into a freed cache/component
segment and strand it across an evict/reload cycle -- the cross-cycle
fragmentation OOM on a tight budget. Pools are released once their
tensors are freed. Shared MemPool helpers (create_mem_pool,
mem_pool_context, supports_mem_pool) live in torch_util so both
BaseModel._move_part and the offload conductor use the same wrapper.

The alignment budget for the offload cache is sized from the actual
offload-tensor count (TENSOR_ALIGNMENT_BYTES per tensor) instead of a
fixed 4KB, since the unguarded ring wrap would otherwise silently
overwrite live weights once a cache tensor holds enough tensors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Squashed history of the mempool branch on top of PR Nerogar#1620 (Arena MemPool + single-buffer offload cache / materialize-evict API): eviction handling for multi-TE samplers, materialize_only_text_encoders() helper, generic BaseModel.eval()/adapters(), per-stem LoRA pooling, and review cleanups.
A part's 'train' flag defaults True even for parts the architecture can't train
(e.g. the frozen text encoder on the newer transformer models), so taking it at
face value misclassifies those parts as trained. Record the architecture-trainable
parts per model type and expose part_trained_in_place() -- train and
architecture-trainable and FINE_TUNE -- for the memory-management modes that must
not silently discard in-place weight updates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…load)

Wires LayerOffloadConductor to materialize/evict layers directly from the
checkpoint (set_disk_materialize, per-layer key_prefix) so disk-offload
load-on-demand and the GPU/CPU layer split can be used together (issue Nerogar#69).

A streamed sub-module is loaded as a meta skeleton; its real weights are
streamed straight from the checkpoint to the compute device and quantized
there on first use, so the full unquantized module never lands in system RAM.
With cache_in_ram off the weights are discarded back to meta after each use
and re-streamed on the next; with it on they stay resident in pinned RAM.

Includes the unpublished part of the materialize()/evict() base work this
depends on (the conductor.to split and the move to evicting only at
save/teardown boundaries).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Discard-offload for the frozen-base (LoRA / embedding / frozen fine-tune) streaming path: when weights never change and `cache_in_ram` is on, per-layer offload stops copying host-ward.

`FullModelLayerAllocator` (temp/CPU-side sibling of the ring `StaticLayerAllocator`) keeps every layer's packed weights in one permanent pinned CPU buffer for the model's lifetime. Offload is a pointer swap back into that buffer — `place()` copies nothing — and onload copies the slot into the GPU ring as before. The buffer is filled once at materialize by an explicit GPU→CPU copy, since quantized weights only exist on the GPU. Initially-loaded layers are dual-resident (GPU ring + dormant CPU slot); evict unpins but keeps the buffer and repoints the loaded layers to their slots with no clone.

The mode gate is `streaming ∧ cache_in_ram ∧ simplex`, where `simplex` (computed in `enable_checkpointing`) is false only for a full fine-tune of a trained part. The hot-path offload (`__schedule_layer_to`) stays branch-free: the no-op `place()` and no-op `deallocate_layer` absorb the difference. Only `__materialize` / `__evict_to_temp` / `__evict_to_meta` carry a simplex branch.

`offload_quantized`'s allocator argument is also generalized into a `place()` functor that owns the copy decision, so the ring, clone, and full-model paths share one call site (the `StaticLayerTensorAllocator.place` / `clone_tensor_allocator` / `pool_clone` refactor).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant