Skip to content

Run Lance inference on low-RAM / multi-GPU consumer hardware (e.g. 8 GB RAM + 5×3060)#43

Open
johbau wants to merge 9 commits into
bytedance:mainfrom
johbau:low-ram-sharded-load
Open

Run Lance inference on low-RAM / multi-GPU consumer hardware (e.g. 8 GB RAM + 5×3060)#43
johbau wants to merge 9 commits into
bytedance:mainfrom
johbau:low-ram-sharded-load

Conversation

@johbau

@johbau johbau commented Jun 19, 2026

Copy link
Copy Markdown

Runs Lance inference on an 8 GB-RAM host with 5× RTX 3060 (12 GB each) — far
below the README's ≥40 GB-VRAM requirement. The headline constraint here is system
RAM, not VRAM
: the stock path materializes the 3B model in fp32 on CPU (~12 GB) before
moving it to GPU, which OOM-kills an 8 GB host during weight init, before any GPU code
runs. This PR removes that CPU spike and shards the model across the small cards.

Validated end-to-end on this box across all seven task types: x2t_image, x2t_video, t2i,
t2v (incl. 768² via tiled decode), i2v, image_edit, and video_edit — via both the CLI
and the Gradio web UI.

Headline: low system-RAM load

Build the model under accelerate.init_empty_weights() (meta device) and stream the
safetensors checkpoint with plain seek/read — no mmap (which a low-RAM/no-swap host
refuses for a 12 GB file) and no full fp32 copy — casting each tensor to bf16 and placing
it directly on its target GPU. Peak CPU RSS stays under ~2 GB instead of ~12 GB. This is
the piece that makes Lance load at all on a small-RAM box, independent of how many GPUs
you have.

Supporting pieces

  • Model-parallel sharding — a device_map + accelerate.dispatch_model spread the
    LLM layers across N GPUs (--shard_num_gpus), with a dedicated card for the VAE on
    generation. flex-attention is swapped for an eager-SDPA dense-mask path that's safe
    across shards.
  • Generation/editing device fixes — device-alignment for the forward_inference
    (KV-cache) gen path, and for the encoded source latent in source-conditioned tasks
    (image_edit / video_edit / i2v) so they don't crash on a cross-device index-put.
  • Tiled VAE decode — overlapping spatial tiles + feather-blend lift the decode
    resolution ceiling (768²+ on a 12 GB card); auto-enables above ~512², off below.
  • Tiled VAE encode — same tiling for the source encode (editing/i2v at higher
    resolution), plus a synchronize that makes the sharded encode safe in async mode.
  • Gradio web UI on the same path — the load helpers are factored into
    common/model/sharded_load.py (one source of truth), and lance_gradio.py now uses
    the identical meta-init + streaming + sharding + tiling load. Its pipeline pool holds
    one model sharded across all GPUs (not one replica per GPU); a lance_gradio.sh
    launcher binds it for remote access. All seven task types were exercised through the
    UI on the 8 GB-RAM / 5×3060 box.
  • Smoke-test scaffolding — single-prompt example configs.

No behavior change for capable hosts

--shard_num_gpus defaults to all visible GPUs and collapses to single-GPU
(dispatch_model skipped) when there's one card; VAE tiling auto-disables below ~512².
Hosts with enough RAM/VRAM behave as before — and load faster, at half the at-rest VRAM,
since weights stay bf16. Design notes in LOW_RAM_LOAD.md, SHARDED_LOAD.md,
TILED_VAE_DECODE.md, TILED_VAE_ENCODE.md.

Related work

Several memory-reduction efforts exist; this one is distinguished by targeting host
RAM
and high-res tiling across all tasks:

  • Add low-memory relay mode for T2I inference #44 (relay mode) — reduces peak VRAM on a single GPU via sequential
    UND→GEN→VAE load/offload, for T2I. Complementary: it could run within a shard here.
  • lvyufeng/Lance-2080ti — independently arrives at the same multi-GPU layer-pipeline +
    VAE-on-a-separate-GPU + eager-SDPA approach (for Turing 2080 Ti / FP16, T2V +
    understanding), and adds MLP token-chunking. The sharding overlaps; what's new here is
    the low-system-RAM streaming load, VAE encode/decode tiling for 768², and full
    task coverage (editing + i2v). The two are composable.

johbau and others added 9 commits June 19, 2026 17:58
Lets inference_lance.py run on hosts with less system RAM than the model
needs to materialize in fp32 on CPU (~12 GB for Lance_3B). On main, the
first call `Qwen2ForCausalLM(llm_config)` allocates a freshly-init'd fp32
3B model on CPU and OOM-kills an 8 GB host before any GPU code runs.

Changes:

- Build LLM / ViT / Lance wrapper under `accelerate.init_empty_weights()`
  so every nn.Parameter is shape-only on the meta device — near-zero CPU
  RAM during construction.

- Replace `safetensors.safe_open()` with a hand-rolled reader that does
  plain seek+read of one tensor at a time. safe_open mmaps the whole 12 GB
  file, which Linux refuses on a host with strict overcommit / no swap
  (ENOMEM). Peak CPU RAM during load is one tensor at a time.

- Pass `dtype=torch.bfloat16` to `set_module_tensor_to_device` so loaded
  values aren't silently upcast back to the meta tensor's fp32 default.
  Without this the model lives at fp32 on the GPU, doubling VRAM and
  breaking the bf16 autocast path (fp32 weights * bf16 activations
  → fp32 output, then index-put into bf16 destination crashes).

- Replace numpy fp64 sin-cos position embeddings with a torch fp32 port
  that computes on the param's device. PositionEmbedding3D._init_weights
  used to peak around 4 GB of CPU RAM building 3 intermediate arrays of
  shape (t*h*w, ~D/3); the GPU version contributes ~zero CPU.

- Materialize any params left on `meta` after the load (the popped
  latent_pos_embed sin-cos buffer) on the target device and re-init.

- Set `PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True` so the per-tensor
  streaming pattern doesn't fragment the CUDA caching allocator into a
  state where large allocations fail despite plenty of free VRAM.

Peak CPU RSS during load stays under ~2 GB. The model loads in bf16
across whatever device the runner picks (cuda:LOCAL_RANK). Multi-GPU
sharding for hosts that can't fit the model on one card is a separate
follow-up — see SHARDED_LOAD.md.

See LOW_RAM_LOAD.md for the full memory profile and per-file rationale.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Builds on the low-RAM streaming load (previous commit) to enable single-
process, model-parallel inference across N GPUs. Lets Lance run on hosts
where no single card has enough VRAM but the aggregate does — e.g.
5 × RTX 3060 (60 GB) for Lance_3B + ViT + VAE.

Changes:

- `_build_lance_device_map(model, num_gpus)` spreads LLM transformer
  layers across cuda:0..N-1 with cuda:0 getting a reduced share (it also
  hosts embed/lm_head/ViT/VAE/connectors). A safety net pins any
  uncovered parameter to cuda:0 so future top-level MoT siblings don't
  break dispatch.

- `accelerate.dispatch_model` installs pre/post forward hooks that move
  activations between cards as needed. The streaming loader from the
  previous commit already routes each tensor onto its target shard at
  load time; this commit just attaches the runtime hooks.

- Replace flex_attention with eager-SDPA in all three call sites in
  lance.py. flex_attention's BlockMask captures device-specific tensors
  that dynamo refuses to combine with Q/K/V from a different shard.
  qwen2_navit.py already has an isinstance(attention_mask, List) → SDPA
  branch that crosses devices cleanly via accelerate hooks. A new helper
  `_flex_mask_to_dense_list` evaluates the flex mask function on a
  (q_idx, kv_idx) meshgrid to produce that List.

- Align parent-class Python combine sites that dispatch_model's hooks
  can't reach:
  - Pin ViT to cuda:0 so its output matches embed_tokens for the inline
    `masked_scatter` in validation_video_to_text.
  - Move `packed_sequence` back to the index tensor's device after the
    Qwen2Model layer loop, so the final norm/lm_head indexing combine
    works.
  - Handle both Tensor and List in the per-layer
    `attention_mask.to(device=…)` call.

- Launcher and config:
  - inference_lance.sh: NUM_GPUS=5 default (now means shard count, not
    data-parallel rank count), `--num_processes 1`, forwards
    `--shard_num_gpus $NUM_GPUS`.
  - InferenceArguments: new `shard_num_gpus: int = 0`. 0 = use all
    visible GPUs; >0 caps to that many.

Behavior on a 1-GPU host is unchanged — the device map collapses to
"everything on cuda:0" and dispatch_model is skipped. The dense-mask
SDPA replacement runs unconditionally; flex_attention can be gated
behind a flag if a single-card user wants the compiled kernel back.

Memory profile on a 5 × 3060 / 8 GB RAM host:
  cuda:0  ~6 GB  (3 LLM layers + ViT + VAE + embed + lm_head + extras)
  cuda:1  ~3 GB  (8 LLM layers)
  cuda:2  ~3 GB  (8 LLM layers)
  cuda:3  ~3 GB  (8 LLM layers)
  cuda:4  ~3 GB  (8 LLM layers)

Smoke test (x2t_image, 768 res, 6 cases) completes successfully. About
67 s per understanding batch — slow because activations shuttle across
PCIe between cards and SDPA is eager. The point is fitting the model on
this hardware, not throughput.

See SHARDED_LOAD.md for the full rationale and per-file summary.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The diffusion + VAE-decode generation path uses forward_inference (KVcache)
and the WanVideoVAE, neither exercised by the understanding path. Three
model-parallel fixes were needed to run t2i/t2v across multiple GPUs:

- forward_inference gen-mode norm (qwen2_navit.py): inference-mode twin of
  the forward_train post-loop fix. After the decoder-layer loop the sequence
  is on the last shard, but the index tensors + norm/norm_moe_gen are on
  cuda:0. Added a .to(packed_text_indexes.device) guard before the gen-mode
  index-put. Runs every diffusion timestep. (The KVcache path attends via
  flash_attn_varlen_func, not flex_attention, so the dense-mask change is not
  exercised here.)

- VAE device override (modeling/vae/wan/model.py): WanVideoVAE hard-coded
  get_device()=cuda:0. It now accepts a device= (default get_device(), so
  single-GPU is unchanged) used by configure_vae_model/vae_encode/vae_decode,
  so the VAE can live on a less-crowded card.

- Dedicated VAE card (inference_lance.py): the video VAE decode's conv
  activations (~9 GB at 480^2 / 17 frames) won't fit on a card that also
  holds LLM layers. For generation tasks, _build_lance_device_map now takes
  reserve_last_for_vae and shards the LLM across the first N-1 cards, leaving
  the last card empty of LLM weights; the VAE is built there.

Confirmed on the 8 GB-RAM / 5x3060 host: t2i produces a clean PNG and t2v
produces a valid 480x480 17-frame h264 mp4 (1.42 s). Note: 768^2 video decode
exceeds a single 12 GB card even when dedicated — that needs VAE decode tiling
(not implemented). The launcher's VIDEO_HEIGHT/WIDTH default to 768, so pass
--VIDEO_HEIGHT 480 --VIDEO_WIDTH 480 for t2v on 12 GB cards. See SHARDED_LOAD.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Convenience for bounded generation smoke tests (one output instead of all
prompts in the example JSON, which apply_inference_defaults expands to
validation_max_samples=100000):

- inference_lance.sh: new --DATASET_CONFIG passthrough that forwards
  --val_dataset_config_file to the Python side.
- config/examples/t2i_single.json, t2v_single.json: first prompt of the
  corresponding example file, so a smoke test generates a single image/video.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Lifts the VAE-decode resolution ceiling (previously ~480-512^2 on a 12 GB
card; 768^2 OOMs even on a card dedicated to the VAE). The decode is already
streamed temporally, so the memory peak is a single frame's full-resolution
conv activations — a spatial problem, not a weight one (so LLM-style layer
sharding wouldn't help). Tiling the latent spatially and feather-blending the
per-tile decodes bounds per-tile memory and lifts the ceiling.

- WanVideoVAE._tiled_decode / _should_tile (modeling/vae/wan/model.py):
  slice the latent [1,48,t,h,w] into overlapping spatial tiles, decode each
  via the existing self.vae.decode (which resets its own temporal feat_cache,
  so each tile is a correct independent temporal stream), and blend into the
  output with a linear edge ramp + weight-sum normalization. Reuses the
  validated decode per tile — no Decoder3d rewrite.
- Config: vae_tile_size (0=auto above ~512^2 latent, >0=force, <0=disable) and
  vae_tile_overlap in InferenceArguments; plumbed through inference_lance.py
  and inference_lance.sh (--VAE_TILE / --VAE_TILE_OVERLAP).
- Blend arithmetic unit-tested off-GPU: reconstructing exact-tile decodes
  matches the source to ~1e-16 across divisible/non-divisible/768^2-latent
  cases with full coverage (wsum >= 1).

TILED_VAE.md documents the design (Approach A: single-GPU tiling, implemented;
Approach B: multi-GPU tile distribution, proposal) and the pending in-container
validation (480^2 parity, 768^2 memory, seams). SHARDED_LOAD.md cross-links it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
video_edit / image_edit crashed with "Expected all tensors on the same
device, cuda:4 and cuda:0" at the source-latent index-put in the generation
loop (lance.py validation_gen / validation_gen_KVcache):

    curr_padded_latent[idx] = x_t[idx]

curr_padded_latent is the VAE-encoded *source* video — built from
val_padded_latent via vae_encode on the reserved VAE card (cuda:4) — while
x_t (the diffusion latent), the index tensors, and the whole denoising loop
run on `device` (cuda:0). Only editing/i2v carry a source latent, so t2v/t2i
never hit this.

Fix: move curr_padded_latent to `device` at the cat/cast point (both gen
methods), so all subsequent combines stay on one device.

Validated: video_edit runs end-to-end on the 8 GB-RAM / 5x3060 box at
--RESOLUTION video_192p (3 example clips). Higher resolution still needs
encode-side VAE tiling (vae_encode is not tiled) — separate follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirror of the decode-side tiling, for the encode path that editing / i2v
tasks use (make_padded_latent -> vae_encode). Without it, encoding a source
video at higher RESOLUTION overruns the dedicated VAE card (the conv
activations at full spatial resolution), so editing only worked at
video_192p.

- WanVideoVAE._tiled_encode / _should_tile_encode (modeling/vae/wan/model.py):
  slice the pixel input into latent-cell-aligned spatial tiles (pixel span =
  latent tile x downsample_spatial, so tiles abut exactly), encode each via
  the existing self.vae.encode (returns mu/log_var; resets its own temporal
  feat_cache per call), feather-blend mu and log_var into full-resolution
  latent canvases with weight-sum normalization, then reparameterize once on
  the stitched tensors in vae_encode (so per-tile sampling noise never seams).
- Auto-enables when the latent grid (H // downsample_spatial) exceeds the same
  ~512^2 threshold as decode; reuses the --VAE_TILE / --VAE_TILE_OVERLAP knobs.
- torch.cuda.synchronize(self.device) at the end of _tiled_encode: the VAE is
  on a non-default card (cuda:N-1) while the current device is cuda:0, so the
  many per-tile kernels run async and a downstream empty_cache() in
  make_padded_latent raced them into an illegal memory access (masked only by
  CUDA_LAUNCH_BLOCKING=1). The sync flushes the VAE card before the result is
  consumed — encode tiling then works in normal async mode.

Validated on the 8 GB-RAM / 5x3060 box:
- Off-GPU: stitch arithmetic exact to ~1e-16, full coverage.
- Encode parity (768^2, deterministic mu): production default (tile 32 /
  overlap 8) = 0.57% rel mean err, converging to the bf16 floor (~0.43%) by
  overlap 16; tiling is exact-to-bf16, no halo-crop needed.
- End-to-end: video_edit at video_480p (752x560, latent 47 -> tiles) produces
  a correct, seam-free edit in normal async mode.

Scope: lifts the spatial/resolution ceiling for editing. Does NOT address the
low-res high-frame-count OOM (temporal axis) nor the multi-clip earlyoom
(system-RAM accumulation across clips) — both separate.

Docs: TILED_VAE_ENCODE.md (design + validation results); TILED_VAE.md renamed
to TILED_VAE_DECODE.md so decode/encode docs are a matching pair. Reference
updates to the new decode filename in SHARDED_LOAD.md, inference_lance.sh,
config/config_factory.py, and model.py comments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the meta-init streaming-load helpers out of inference_lance.py into a shared
module so the Gradio app can reuse the exact same load path (one source of truth):
resolve_lance_checkpoint, build_lance_device_map, device_for_param, the safetensors
header/tensor readers, stream_load_into, materialize_remaining_meta, plus
POPPED_FROM_CHECKPOINT. inference_lance.py imports them (aliased to its prior
underscore names) — no behavior change to the CLI path.

Pending: re-run a CLI task on-box to confirm the refactor is byte-identical in
behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make lance_gradio.py use the same meta-init + streaming + multi-GPU sharding +
VAE-tiling path as the CLI (via common/model/sharded_load.py), so the web UI runs
on the 8 GB-RAM / 5x3060 box. Previously it used the stock single-GPU loader
(fp32 CPU materialization + model.to(device)) and also imported a function
(init_from_model_path_if_needed) our branch had already removed — so it could not
load on this hardware (or even import).

- LanceT2VV2TPipeline takes a *shard GPU set* (not a single device_id):
  meta-init LLM/ViT/Lance under init_empty_weights(), stream weights onto the
  shards at bf16 via stream_load_into, build_lance_device_map (+ reserve last card
  for the VAE on generation), dispatch_model, materialize_remaining_meta, skip
  init_moe. No model.to() (it would collapse the shards). VAE built on the reserved
  card with tiling (vae_tile_size/overlap).
- PipelinePool now holds ONE model sharded across all GPUs, not one full replica
  per GPU (concurrency stays 1). unload() no longer .cpu()s the dispatched model
  (accelerate forbids whole-model moves) and frees every shard's cache.
- PYTORCH_CUDA_ALLOC_CONF default gains expandable_segments:True (needed by the
  streaming load). New lance_gradio.sh launcher (--gpus 0,1,2,3,4, binds
  0.0.0.0:7860); note podman_run must publish the UI port.
- Guards a contiguous-GPU requirement (device_map uses logical cuda:0..N-1).

Validated: all 7 task types (x2t_image, x2t_video, t2i, t2v, i2v, image_edit,
video_edit) run end-to-end through the Gradio UI on the 8 GB-RAM / 5x3060 box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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