Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
551 changes: 339 additions & 212 deletions README.md

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions TODO
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
- queue management
- past runs, cancel active runs
- models side panel
- templates
- keep runs active between project switch
- provide gen node in trainer tab for quick test with different lora strenths
- Run Graph drop down, download workflow json
8 changes: 4 additions & 4 deletions TRAINING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

The full reference for Inline Studio's **Trainer**: which base to train on, what it costs in VRAM
on a real card, and every setting that shapes the result. For the short version and a screenshot of
the canvas, see [Train a LoRA in the README](README.md#train-a-lora).
the canvas, see [LoRA training in the README](README.md#lora-training).

Inline Studio trains LoRAs for **Z-Image Turbo**, **FLUX.2**, **Krea 2** and **MiniMax H3** on your own
Inline Studio trains LoRAs for **Z-Image**, **Krea 2**, **FLUX.2** and **MiniMax H3** on your own
GPU, with no cloud step and nothing uploaded. Training is cheaper than generating: a 16GB card
trains all three image models at 512px, and a LoRA trained at 512 applies at any generation
resolution.
Expand Down Expand Up @@ -56,7 +56,7 @@ The Trainer's Adjust panel picks the **architecture** first (Z-Image, Krea 2, FL

**MiniMax H3** is the video model, and it trains on **still images**:

- **FL2VA** is the only base, and it is undistilled, so there is no adapter and nothing to drift. Put `minimax_h3_fl2va_bf16.safetensors` in `models/diffusion_models/`, train on stills, then wire the LoRA into any of the four H3 nodes. It loads on the Reference to Video node too, which uses a different checkpoint file: the two partitions are the same architecture.
- **FL2VA** is the only base, and it is undistilled, so there is no adapter and nothing to drift. Put `minimax_h3_fl2va_bf16.safetensors` in `models/diffusion_models/`, train on stills, then wire the LoRA into any of the four H3 nodes. **It has to be the bf16 file.** The smaller `pruned` and `pruned_fp8_scaled` builds generate but cannot train: they ship no timestep path for the modulation basis to be derived from, and they would save nothing anyway, because the base trains at 4-bit whichever file it starts from. The trainer says so rather than failing part way in. It loads on the Reference to Video node too, which uses a different checkpoint file: the two partitions are the same architecture.
- **Stills or short clips.** Drop images and it learns appearance: look, style, character, lighting. Drop video and it learns motion too. Sound is never learned either way, because the audio rows are empty. See [Training on clips](#training-on-clips).
- **The base is 4-bit, always.** H3 is 40GB after the AdaLN factorisation and 11.7GB after quantisation, so full precision is refused rather than offered and then failing. There is no base-precision control for H3 for the same reason.
- **A 24GB card is comfortable and a 16GB card works, slowly.** The run encodes latents and captions in two passes that never overlap, because H3's fp32 video VAE and its 32B conditioner cannot be resident together. On a card that holds the conditioner it peaks at 20.6GB; on one that does not, the conditioner runs on the CPU and the peak drops to 12.7GB while a step goes from 0.6s to 16s. Either way there is about seven minutes of startup, and 64GB of system RAM for the smaller card. See [Benchmark results](#benchmark-results) for the split. The download is about 124GB before any of that.
Expand Down Expand Up @@ -104,7 +104,7 @@ audio rows, so an adapter changes what a clip looks like and never what it sound

## Install

If you installed with `--extra all` from [Install](README.md#install), the trainer is already set up - nothing more to do. To add it to a leaner install, its dependencies (PEFT, 8-bit Adam, the captioner) sit behind the `training` extra:
If you installed with `--extra all` from [Get Started](README.md#get-started), the trainer is already set up - nothing more to do. To add it to a leaner install, its dependencies (PEFT, 8-bit Adam, the captioner) sit behind the `training` extra:

```bash
cd core
Expand Down
16 changes: 16 additions & 0 deletions core/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,17 @@ between nodes and are never takes.
→ sequential offload → wont-fit. NF4 is what makes a 32B model viable on a 24 GB card. Note int8
forces bf16 (torchao's weight-only int8 silently no-ops under fp16) while NF4 does not, so a Turing
card keeps its fp16 tensor cores under NF4.
- **A pre-reduced checkpoint must not be re-reduced, structurally or numerically.** MiniMax H3's
`pruned` builds ship the AdaLN branch already factorised to rank 8 and drop the timestep path
entirely, so re-running our factorisation multiplies a `[96768, 8]` projection by a full-width
basis. `minimaxh3/pipeline.py` turns `factorise_adaln` off for those, the same way the rule below
turns quantization off for a prequantized file. Both are the same rule: the source is already in
the target form.
- **Size a checkpoint by what it becomes, not by what it weighs.** A pruned file has already lost
its AdaLN branch and an fp8 file stores half the bytes it will occupy once dequantised, so scaling
the on-disk number under-sizes both, by up to 3x. `minimaxh3.requirements.resident_bytes` counts
from the header. Under-sizing is the dangerous direction: the fit ladder then promises a machine
that dies to a host-RAM OOM kill instead of raising.
- **Prequantized checkpoints must not be re-quantized.** A checkpoint that ships already quantized
(`flux2/variants.is_prequantized`) has an on-disk size that already _is_ its resident size, so the
ladder's assumption that quantization halves it does not hold, and handing diffusers a second,
Expand Down Expand Up @@ -230,6 +241,11 @@ real codec that moves tensors lives with the model runner.
Don't scatter it.
- **Bring-your-own models.** Nothing is downloaded by the engine. The catalog scans; the user places
files. A model picker is a `SELECT` param with `options_from="<category>"`.
- **Adapter strength is not a quality metric, and a threshold on it is a false-positive machine.**
Measured against real bases, published LoRAs that work well span `|B@A| / |W|` from 0.017%
(a style LoRA) to 1.2% (a restoration LoRA), so "this adapter looks weak" is not a finding. What
predicts a LoRA doing nothing is whether its per-weight change clears one quantization step. Warn
on that, and only when the base is actually quantized.
- **Verify image models by rendering.** The FLUX.2 work shipped five bugs past a green test suite,
and every one produced a _wrong image rather than an error_: a mis-keyed checkpoint, a
vision-language encoder loaded in place of a text one, an unnormalized latent, and a control context
Expand Down
31 changes: 23 additions & 8 deletions core/src/inline_core/models/keymap.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,24 +262,39 @@ def transform(
raise ComponentError(f"{key} has {tensor.shape[0]} rows, not divisible into {parts} parts.")
if verify_layout:
assert_layout(tensor, action, key=key)
source = _deinterleave(tensor, parts, action.head_dim) if (
source = deinterleave_rows(tensor, parts, action.head_dim) if (
action.layout is RowLayout.INTERLEAVED
) else tensor
block = source.shape[0] // parts
for index, target in enumerate(action.targets):
yield target, source[index * block : (index + 1) * block]


def _deinterleave(tensor: Any, parts: int, head_dim: int) -> Any:
"""``[p0_h0; p1_h0; p2_h0][p0_h1; …]`` to ``[p0_all; p1_all; p2_all]``.

``transpose`` is not the same call in torch and numpy - torch swaps two axes, numpy wants a full
permutation - so the swap is spelled per backend rather than duck-typed.
"""
def deinterleave_rows(tensor: Any, parts: int, head_dim: int) -> Any:
"""``[p0_h0; p1_h0; p2_h0][p0_h1; …]`` to ``[p0_all; p1_all; p2_all]``."""
if head_dim < 1:
raise ComponentError("De-interleaving needs the head dimension the parts are grouped by.")
heads = tensor.shape[0] // (parts * head_dim)
reshaped = tensor.reshape(heads, parts, head_dim, *tensor.shape[1:])
return _swap01(tensor, (heads, parts, head_dim))


def interleave_rows(tensor: Any, parts: int, head_dim: int) -> Any:
"""``[p0_all; p1_all; p2_all]`` back to per-head groups: the inverse of ``deinterleave_rows``.

Needed to *write* a checkpoint or adapter in a publisher's interleaved layout, where the load
path only ever reads one."""
if head_dim < 1:
raise ComponentError("Interleaving needs the head dimension the parts are grouped by.")
heads = tensor.shape[0] // (parts * head_dim)
return _swap01(tensor, (parts, heads, head_dim))


def _swap01(tensor: Any, shape: tuple[int, int, int]) -> Any:
"""Reshape to ``shape`` plus the trailing dims, exchange the first two, flatten back.

``transpose`` is not the same call in torch and numpy - torch swaps two axes, numpy wants a full
permutation - so the swap is spelled per backend rather than duck-typed."""
reshaped = tensor.reshape(*shape, *tensor.shape[1:])
if _is_torch(tensor):
moved = reshaped.transpose(0, 1).contiguous()
else:
Expand Down
98 changes: 70 additions & 28 deletions core/src/inline_core/models/lora.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
#: while ostris' training adapter uses the reference names.
Alias = Callable[[str], str | None]

#: Rewrites a whole adapter before it is matched, for an arch whose checkpoint keys need more than a
#: rename - MiniMax H3 ships attention fused, so three of our modules are one of theirs.
Translate = Callable[[dict[str, Any]], dict[str, Any]]

_DOWN = ("lora_down.weight", "lora_A.weight", "lora_A.default.weight")
_UP = ("lora_up.weight", "lora_B.weight", "lora_B.default.weight")
# Prefixes checkpoints put in front of the module path; stripped when matching against the model.
Expand All @@ -39,12 +43,22 @@
LoraPlan = dict[str, list[tuple[Any, Any, float]]]


def fuse_loras(model: Any, loras: tuple[LoraRef, ...], alias: Alias | None = None) -> None:
def fuse_loras(
model: Any,
loras: tuple[LoraRef, ...],
alias: Alias | None = None,
translate: Translate | None = None,
) -> None:
"""Merge each LoRA into ``model``'s weights in order. No-op for an empty stack."""
apply_plan(model, plan_loras(model, loras, alias))
apply_plan(model, plan_loras(model, loras, alias, translate))


def plan_loras(model: Any, loras: tuple[LoraRef, ...], alias: Alias | None = None) -> LoraPlan:
def plan_loras(
model: Any,
loras: tuple[LoraRef, ...],
alias: Alias | None = None,
translate: Translate | None = None,
) -> LoraPlan:
"""Resolve every LoRA against ``model``'s module names, without touching any weights.

Split from the fusing so a streaming loader can validate the whole stack **before** reading a
Expand All @@ -53,7 +67,7 @@ def plan_loras(model: Any, loras: tuple[LoraRef, ...], alias: Alias | None = Non
plan: LoraPlan = {}
names = _linear_module_names(model)
for lora in loras:
_plan_one(plan, names, lora.file, lora.strength, alias)
_plan_one(plan, names, lora.file, lora.strength, alias, translate)
return plan


Expand All @@ -74,7 +88,12 @@ def apply_plan(module: Any, plan: LoraPlan, prefix: str = "") -> None:


def _plan_one(
plan: LoraPlan, names: dict[str, None], path: str, strength: float, alias: Alias | None
plan: LoraPlan,
names: dict[str, None],
path: str,
strength: float,
alias: Alias | None,
translate: Translate | None = None,
) -> None:
from safetensors.torch import load_file

Expand All @@ -83,6 +102,8 @@ def _plan_one(
except Exception as exc: # noqa: BLE001
raise ComponentError(f"Could not read LoRA {path!r}: {exc}") from exc

if translate is not None:
state = translate(state)
pairs, alphas = _group(state)
if not pairs:
raise ComponentError(f"LoRA {path!r} contains no recognisable lora_down/lora_up pairs.")
Expand All @@ -104,27 +125,39 @@ def _plan_one(
)


#: Most fp32 delta held at once. The product is computed a slice of output rows at a time because
#: the whole of it is enormous: one MiniMax H3 block's six Linears come to 1.5GB and the model to
#: 80GB, which is host RAM during a staged load and took a 60GB box down three times.
_DELTA_CHUNK_BYTES = 64 * 1024 * 1024


def _add_delta(weight: Any, up: Any, down: Any, scale: float) -> None:
"""Fuse ``scale * (up @ down)`` into ``weight`` in place.

Computed on the weight's own device: a big LoRA (Krea 2's are ~260 modules on a 12.9B model)
materializes tens of GB of fp32 deltas, and doing that on the CPU costs ~20s of maths plus the
transfer where the GPU takes ~2s. Falls back to the CPU if the device runs out of memory, so a
tight card still fuses, just slowly."""
Computed on the weight's own device: doing it on the CPU costs ~20s of maths plus the transfer
where the GPU takes ~2s. Falls back to the CPU if the device runs out of memory, so a tight card
still fuses, just slowly."""
import torch

try:
weight.add_(_delta(up, down, weight, weight.device) * scale)
_accumulate(weight, up, down, scale, weight.device)
except torch.cuda.OutOfMemoryError:
weight.add_((_delta(up, down, weight, "cpu") * scale).to(weight.device, weight.dtype))
_accumulate(weight, up, down, scale, "cpu")


def _delta(up: Any, down: Any, weight: Any, device: Any) -> Any:
"""``up @ down`` on ``device``, shaped and typed for the target weight. Conv LoRAs flatten the
spatial dims."""
def _accumulate(weight: Any, up: Any, down: Any, scale: float, device: Any) -> None:
"""Add the product into ``weight`` in row slices. Conv LoRAs flatten the spatial dims."""
dtype = _fuse_dtype(up)
delta = up.to(device, dtype=dtype).flatten(1) @ down.to(device, dtype=dtype).flatten(1)
return delta.reshape(weight.shape).to(weight.dtype)
rows = up.to(device, dtype=dtype).flatten(1)
cols = down.to(device, dtype=dtype).flatten(1)
if not weight.is_contiguous(): # a non-contiguous target cannot be written through a view
weight.add_((rows @ cols).reshape(weight.shape).to(weight.dtype) * scale)
return
target = weight.view(rows.shape[0], -1)
step = max(1, _DELTA_CHUNK_BYTES // max(1, cols.shape[1] * 4))
for start in range(0, rows.shape[0], step):
stop = start + step
target[start:stop].add_((rows[start:stop] @ cols).to(weight.dtype) * scale)


def _fuse_dtype(tensor: Any) -> Any:
Expand All @@ -141,20 +174,29 @@ def _alpha_scale(alpha: Any, rank: int) -> float:
return float(alpha.item() if hasattr(alpha, "item") else alpha) / float(rank)


def split_key(key: str) -> tuple[str, str] | None:
"""``…to_q.lora_A.weight`` to ``("…to_q", "down")``. None for anything that is not a LoRA key.

Public so a per-arch key translator groups by exactly the suffixes the fuser recognises: a
convention known to one and not the other would drop tensors silently."""
for suffix in _DOWN:
if key.endswith("." + suffix):
return key[: -len(suffix) - 1], "down"
for suffix in _UP:
if key.endswith("." + suffix):
return key[: -len(suffix) - 1], "up"
if key.endswith(".alpha"):
return key[: -len(".alpha")], "alpha"
return None


def _group(state: dict[str, Any]) -> tuple[dict[str, tuple[Any, Any]], dict[str, Any]]:
downs: dict[str, Any] = {}
ups: dict[str, Any] = {}
alphas: dict[str, Any] = {}
parts: dict[str, dict[str, Any]] = {}
for key, value in state.items():
for suffix in _DOWN:
if key.endswith("." + suffix):
downs[key[: -len(suffix) - 1]] = value
for suffix in _UP:
if key.endswith("." + suffix):
ups[key[: -len(suffix) - 1]] = value
if key.endswith(".alpha"):
alphas[key[: -len(".alpha")]] = value
return {k: (downs[k], ups[k]) for k in downs if k in ups}, alphas
if (split := split_key(key)) is not None:
parts.setdefault(split[0], {})[split[1]] = value
pairs = {k: (v["down"], v["up"]) for k, v in parts.items() if "down" in v and "up" in v}
return pairs, {k: v["alpha"] for k, v in parts.items() if "alpha" in v}


def _linear_module_names(model: Any) -> dict[str, None]:
Expand Down
Loading
Loading