Add support to train MOSS Image Base as an edit model

This commit is contained in:
Jaret Burkett committed 2026-10-16 12:19:06 -06:00
1 parent 0d19b26481
commit b7cd8a13af
7 files changed
+548 -58

No files matched your search

@@ -18,13 +18,17 @@ stock Qwen3.5-9B from a comfy file.
Flow convention matches ai-toolkit (sigma 1 = noise, target = noise - clean).
"""
import math
import os
from typing import TYPE_CHECKING, List, Optional
import torch
import torch.nn.functional as F
from PIL import Image
from safetensors import safe_open
from safetensors.torch import load_file
from transformers import Qwen2Tokenizer
from torchvision.transforms.functional import to_tensor
from transformers import AutoImageProcessor, Qwen2Tokenizer
from toolkit.accelerator import unwrap_model
from toolkit.advanced_prompt_embeds import AdvancedPromptEmbeds
@@ -32,7 +36,10 @@ from toolkit.basic import flush
from toolkit.config_modules import GenerateImageConfig, ModelConfig
from toolkit.metadata import get_meta_for_safetensors
from toolkit.models.base_model import BaseModel
from toolkit.models.v2.text_encoders.qwen35 import Qwen35TextOnlyEncoder
from toolkit.models.v2.text_encoders.qwen35 import (
Qwen35TextOnlyEncoder,
Qwen35VLEncoder,
)
from toolkit.models.v2.vae.flux2_kl import AutoEncoder, convert_diffusers_state_dict
from toolkit.samplers.custom_flowmatch_sampler import (
CustomFlowMatchEulerDiscreteScheduler,
@@ -98,6 +105,33 @@ def _hub_tokenizer_folder(repo_id: str) -> str:
return os.path.join(root, "tokenizer")
def _hub_file_folder(repo_id: str, filename: str) -> str:
"""Folder holding one hub file, cache first (loaded from the folder so no
other file of the repo is resolved)."""
from huggingface_hub import hf_hub_download
try:
path = hf_hub_download(repo_id, filename, token=HF_TOKEN, local_files_only=True)
except Exception:
path = hf_hub_download(repo_id, filename, token=HF_TOKEN)
return os.path.dirname(path)
def _drop_repeats(images):
"""ctrl_img_1 defaults to ctrl_img, so a single reference can arrive twice."""
kept = []
for image in images:
previous = kept[-1] if kept else None
if (
previous is not None
and previous.shape == image.shape
and torch.equal(previous, image)
):
continue
kept.append(image)
return kept
class MossImageModel(BaseModel):
arch = "moss_image"
@@ -128,6 +162,18 @@ class MossImageModel(BaseModel):
kw.get("uncond_skip_layers", DEFAULT_UNCOND_SKIP_LAYERS) or ()
)
# model_kwargs.edit: krea2-style reference conditioning for edit LoRAs
# (the vendor model is t2i only). References ride in the prompt through
# the Qwen3.5 vision tower and as clean t=0 latents after the text.
# kv_cache: train with references attending only to themselves so
# their K/V are computed once per sample at inference.
self.is_edit = bool(kw.get("edit", False))
self.kv_cache = bool(kw.get("kv_cache", False))
self.encode_control_in_text_embeddings = self.is_edit
self.has_multiple_control_images = self.is_edit
self.use_raw_control_images = self.is_edit
self.image_processor = None
@staticmethod
def get_train_scheduler():
return CustomFlowMatchEulerDiscreteScheduler(
@@ -194,7 +240,9 @@ class MossImageModel(BaseModel):
if te_path.endswith(".safetensors"):
config_path = QWEN35_9B_REPO
self.print_and_status_update(f"Loading Qwen3.5 text encoder from {te_path}")
text_encoder = Qwen35TextOnlyEncoder.load_model(
# edit keeps the vision tower for the reference images
te_cls = Qwen35VLEncoder if self.is_edit else Qwen35TextOnlyEncoder
text_encoder = te_cls.load_model(
te_path,
dtype=te_kwargs["dtype"],
config_path=config_path,
@@ -203,6 +251,12 @@ class MossImageModel(BaseModel):
quantize_on_load=False,
use_comfy_weights=te_kwargs["use_comfy_weights"],
)
if self.is_edit:
text_encoder.patch_vision_patch_embed()
# MOSS ships no image processor; Qwen's (same template + vocab)
self.image_processor = AutoImageProcessor.from_pretrained(
_hub_file_folder(QWEN35_9B_REPO, "preprocessor_config.json")
)
text_encoder.eval()
text_encoder.requires_grad_(False)
return text_encoder
@@ -294,6 +348,27 @@ class MossImageModel(BaseModel):
gen_config.width = int(gen_config.width // sc * sc)
gen_config.height = int(gen_config.height // sc * sc)
# reference latents; the prompt embeds already carry the references
# through the vision tower (ctrl_img_1 mirrors ctrl_img when unset)
ref_latents = None
if self.is_edit:
ctrl_paths = []
for path in (
gen_config.ctrl_img,
gen_config.ctrl_img_1,
gen_config.ctrl_img_2,
gen_config.ctrl_img_3,
):
if path is not None and path not in ctrl_paths:
ctrl_paths.append(path)
if ctrl_paths:
ref_latents = [
self._encode_ref_latents(
[to_tensor(Image.open(p).convert("RGB")) for p in ctrl_paths],
target_pixels=gen_config.width * gen_config.height,
)
]
result = pipeline(
conditional_embeds=conditional_embeds,
unconditional_embeds=unconditional_embeds,
@@ -305,9 +380,86 @@ class MossImageModel(BaseModel):
generator=generator,
shift=self.sample_shift,
uncond_skip_layers=self.uncond_skip_layers,
ref_latents=ref_latents,
isolate_refs=self.kv_cache,
)
return result[0]
# ------------------------------------------------------------------
# Reference images (edit)
# ------------------------------------------------------------------
def _ref_target_pixels(self, target_pixels: Optional[int]) -> int:
"""Area each reference is fitted to: control_image_max_pixels (1 MP
cap, only scales down), or the target's area with match_target_res."""
kw = self.model_config.model_kwargs
if kw.get("match_target_res", False) and target_pixels:
return int(target_pixels)
return int(kw.get("control_image_max_pixels", 1024 * 1024))
def _encode_ref_latents(
self, control_tensors, target_pixels: Optional[int] = None
) -> List[torch.Tensor]:
"""[0, 1] (C, H, W) / (1, C, H, W) references -> (128, h, w) latents,
aspect kept, snapped to the 16 px grid."""
sc = self.get_bucket_divisibility()
budget = self._ref_target_pixels(target_pixels)
match = self.model_config.model_kwargs.get("match_target_res", False)
latents = []
for img in control_tensors:
if img.dim() == 3:
img = img.unsqueeze(0)
img = img.to(self.device_torch, dtype=torch.float32)
h, w = img.shape[2], img.shape[3]
if match or h * w > budget:
ratio = h / w
new_h = math.sqrt(budget * ratio)
new_w = new_h / ratio
else:
new_h, new_w = float(h), float(w)
new_h = max(sc, int(round(new_h / sc)) * sc)
new_w = max(sc, int(round(new_w / sc)) * sc)
if (new_h, new_w) != (h, w):
img = F.interpolate(img, size=(new_h, new_w), mode="bilinear")
latent = self.encode_images(
img * 2 - 1, device=self.device_torch, dtype=self.torch_dtype
)
latents.append(latent[0])
return latents
def _batch_ref_latents(
self, batch: "DataLoaderBatchDTO", batch_size: int, target_pixels: int
) -> Optional[List[List[torch.Tensor]]]:
control_list = batch.control_tensor_list
if control_list is None and batch.control_tensor is not None:
control_list = [batch.control_tensor[b : b + 1] for b in range(batch_size)]
if control_list is None:
return None
if len(control_list) != batch_size:
raise ValueError("control tensor list length does not match batch size")
return [
self._encode_ref_latents(controls, target_pixels=target_pixels)
for controls in control_list
]
def _prep_vlm_images(self, images: List[torch.Tensor]) -> List[torch.Tensor]:
"""References for the vision tower: downscaled (never up) to
vlm_max_pixels (384^2 = 144 tokens); detail comes from the latents."""
target = int(self.model_config.model_kwargs.get("vlm_max_pixels", 384 * 384))
out = []
for img in images:
if img.dim() == 4:
img = img[0]
img = img.to(self.device_torch, dtype=torch.float32)
h, w = img.shape[1], img.shape[2]
scale = min(1.0, math.sqrt(target / (h * w)))
nh, nw = max(round(h * scale), 32), max(round(w * scale), 32)
if (nh, nw) != (h, w):
img = F.interpolate(
img[None], size=(nh, nw), mode="bicubic", antialias=True
)[0].clamp(0, 1)
out.append(img)
return out
# ------------------------------------------------------------------
# Training hooks
# ------------------------------------------------------------------
@@ -333,36 +485,95 @@ class MossImageModel(BaseModel):
t = timestep.to(device, dtype=torch.float32).reshape(-1)
if t.shape[0] != batch_size:
t = t.expand(batch_size)
ref_latents = None
if self.is_edit and batch is not None:
_, _, lh, lw = latent_model_input.shape
ref_latents = self._batch_ref_latents(
batch, batch_size, target_pixels=lh * lw * 16 * 16
)
return self.transformer(
latent_model_input.to(device, dtype), t, embeds, mask
latent_model_input.to(device, dtype),
t,
embeds,
mask,
ref_latents=ref_latents,
isolate_refs=self.kv_cache,
)
def _chat(self, content: str) -> str:
return self.tokenizer.apply_chat_template(
[{"role": "user", "content": content}],
tokenize=False,
add_generation_prompt=True,
enable_thinking=True,
)
def _encode_with_images(self, text: str, images: List[torch.Tensor]) -> torch.Tensor:
"""Prompt with references ahead of it (``Picture N: <vision>``, the
Qwen-Image-Edit-Plus layout) -> (L, 4096) final states."""
pixels = self.image_processor(
images=self._prep_vlm_images(images), return_tensors="pt", do_rescale=False
)
grid = pixels["image_grid_thw"]
counts = (grid.prod(-1) // self.image_processor.merge_size**2).tolist()
content = "".join(
f"Picture {i + 1}: <|vision_start|>{'<|image_pad|>' * n}<|vision_end|>"
for i, n in enumerate(counts)
)
# no truncation: the image-pad runs must stay whole
input_ids = self.tokenizer(self._chat(content + text), return_tensors="pt")[
"input_ids"
].to(self.device_torch)
mm_token_type_ids = (input_ids == self.text_encoder.config.image_token_id).int()
if int(mm_token_type_ids.sum()) != sum(counts):
raise ValueError("image placeholder count does not match the image grids")
out = self.text_encoder(
input_ids=input_ids,
pixel_values=pixels["pixel_values"].to(self.device_torch, self.torch_dtype),
image_grid_thw=grid.to(self.device_torch),
mm_token_type_ids=mm_token_type_ids,
use_cache=False,
)
return out.last_hidden_state[0]
@torch.no_grad()
def get_prompt_embeds(self, prompt) -> AdvancedPromptEmbeds:
def get_prompt_embeds(self, prompt, control_images=None) -> AdvancedPromptEmbeds:
if isinstance(prompt, str):
prompt = [prompt]
if self.text_encoder.device == torch.device("cpu"):
self.text_encoder.to(self.device_torch)
# control images -> one list per prompt: a (B, C, H, W) batch, a list of
# per-sample lists, or a flat list for a single prompt
if control_images is not None and self.is_edit:
if isinstance(control_images, torch.Tensor):
control_images = [[control_images[i]] for i in range(control_images.shape[0])]
elif len(control_images) > 0 and not isinstance(control_images[0], list):
control_images = [control_images]
if len(control_images) == 1 and len(prompt) > 1:
control_images = control_images * len(prompt)
if len(control_images) != len(prompt):
raise ValueError("number of prompts must match number of control image sets")
control_images = [_drop_repeats(c) if c else None for c in control_images]
else:
control_images = [None] * len(prompt)
embeds = []
# one unpadded forward per prompt, like the vendor (padding would leak
# into the gated-delta recurrence)
for text in prompt:
formatted = self.tokenizer.apply_chat_template(
[{"role": "user", "content": text}],
tokenize=False,
add_generation_prompt=True,
enable_thinking=True,
)
input_ids = self.tokenizer(
formatted,
return_tensors="pt",
truncation=True,
max_length=self.max_text_length,
)["input_ids"].to(self.device_torch)
out = self.text_encoder(input_ids=input_ids, use_cache=False)
embeds.append(out.last_hidden_state[0].to(self.torch_dtype))
for text, images in zip(prompt, control_images):
if images:
states = self._encode_with_images(text, images)
else:
input_ids = self.tokenizer(
self._chat(text),
return_tensors="pt",
truncation=True,
max_length=self.max_text_length,
)["input_ids"].to(self.device_torch)
states = self.text_encoder(input_ids=input_ids, use_cache=False).last_hidden_state[0]
embeds.append(states.to(self.torch_dtype))
return AdvancedPromptEmbeds(text_embeds=embeds)
def get_loss_target(self, *args, **kwargs):
@@ -95,6 +95,8 @@ class MossImagePipeline:
generator: Optional[torch.Generator] = None,
shift: float = DEFAULT_SHIFT,
uncond_skip_layers: Sequence[int] = DEFAULT_UNCOND_SKIP_LAYERS,
ref_latents: Optional[List[List[torch.Tensor]]] = None,
isolate_refs: bool = False,
**kwargs,
):
model = self.model
@@ -115,13 +117,27 @@ class MossImagePipeline:
latents = randn_tensor(shape, generator=generator, device=device, dtype=torch.float32)
latents = latents.to(device, torch.float32)
# edit: references ride in both branches. Isolated references get
# their K/V computed on the first step and reused after; one cache per
# branch (the reference RoPE follows the prompt length, and the
# negative branch skips blocks)
has_refs = ref_latents is not None and any(len(r) > 0 for r in ref_latents)
ref_kwargs = {}
cond_cache = uncond_cache = None
if has_refs:
ref_kwargs = {"ref_latents": ref_latents, "isolate_refs": isolate_refs}
if isolate_refs:
cond_cache, uncond_cache = {}, {}
sigmas = moss_sigmas(num_inference_steps, shift=shift).to(device)
num_steps = len(sigmas) - 1
for step_index in range(num_steps):
sigma, sigma_next = sigmas[step_index], sigmas[step_index + 1]
timestep = (sigma * NUM_TRAIN_TIMESTEPS).reshape(1)
# CFG combines fp32 predictions even though the DiT runs in bf16
pred = transformer(latents, timestep, cond, cond_mask).float()
pred = transformer(
latents, timestep, cond, cond_mask, ref_kv_cache=cond_cache, **ref_kwargs
).float()
if do_cfg:
pred_uncond = transformer(
latents,
@@ -129,6 +145,8 @@ class MossImagePipeline:
uncond,
uncond_mask,
skip_layers=uncond_skip_layers,
ref_kv_cache=uncond_cache,
**ref_kwargs,
).float()
pred = pred_uncond + guidance_scale * (pred - pred_uncond)
if getattr(model, "sample_step_hook", None) is not None:
@@ -10,10 +10,15 @@ length; here a batch runs together with padded text masked out of every
attention (identical results) and the unmasked path when lengths match.
Activations follow the weight dtype exactly like the vendor's bf16 inference:
fp32 timestep sinusoid and RoPE phases, everything else in the compute dtype.
Edit LoRAs (not part of the vendor model): clean reference
latents ride after the text tokens with t=0 modulation, each on its own index
of the RoPE axis the vendor leaves at 0, optionally isolated so their per-block
K/V can be cached across sampling steps.
"""
from dataclasses import dataclass
from typing import Iterable, Optional
from typing import Iterable, List, Optional
import torch
import torch.nn.functional as F
@@ -59,11 +64,24 @@ def _rotate(x, rope):
return x * cos + torch.cat((-b, a), -1) * sin
def _attention(q, k, v, rope, heads, mask):
def _attention(q, k, v, rope, heads, mask, ref_span=None, kv_capture=None, kv_cache=None):
shape = (*q.shape[:2], heads, q.shape[-1] // heads)
q = _rotate(q.reshape(shape), rope).transpose(1, 2)
k = _rotate(k.reshape(shape), rope).transpose(1, 2)
v = v.reshape(shape).transpose(1, 2)
if kv_capture is not None:
# post-RoPE reference K/V, reused as extra keys on later steps. Copies:
# slices would pin the full-sequence K and the whole fused qkv output
# (~4x the cache) for the rest of sampling
kv_capture.append(
tuple(
t[:, :, ref_span[0] : ref_span[1]].clone(memory_format=torch.contiguous_format)
for t in (k, v)
)
)
if kv_cache is not None:
k = torch.cat((k, kv_cache[0].to(k.dtype)), 2)
v = torch.cat((v, kv_cache[1].to(v.dtype)), 2)
out = F.scaled_dot_product_attention(q.to(v.dtype), k.to(v.dtype), v, attn_mask=mask)
return out.transpose(1, 2).flatten(2)
@@ -112,17 +130,46 @@ class _Block(nn.Module):
}
)
def forward(self, x, rope, modulation, mask):
shift_a, scale_a, gate_a, shift_f, scale_f, gate_f = modulation.unbind(1)
y = self.norm1(x) * (1 + scale_a[:, None]) + shift_a[:, None]
def forward(self, x, rope, modulation, mask, ref_span=None, kv_capture=None, kv_cache=None):
# modulation (B, 6, D) [shift_a, scale_a, gate_a, shift_f, scale_f,
# gate_f], or (mod, ref_mod, split): tokens [split:] are clean
# references modulated at t=0
if isinstance(modulation, tuple):
mod, ref_mod, split = modulation
m, r = mod.unbind(1), ref_mod.unbind(1)
def affine(h, scale, shift):
return torch.cat(
(
h[:, :split] * (1 + m[scale][:, None]) + m[shift][:, None],
h[:, split:] * (1 + r[scale][:, None]) + r[shift][:, None],
),
1,
)
def gate(h, g):
return torch.cat(
(h[:, :split] * m[g][:, None], h[:, split:] * r[g][:, None]), 1
)
else:
m = modulation.unbind(1)
def affine(h, scale, shift):
return h * (1 + m[scale][:, None]) + m[shift][:, None]
def gate(h, g):
return h * m[g][:, None]
y = affine(self.norm1(x), 1, 0)
q, k, v = self.proj["qkv"](y).chunk(3, -1)
q = self.q_norm(q.unflatten(-1, (self.heads, -1))).flatten(2)
k = self.k_norm(k.unflatten(-1, (self.heads, -1))).flatten(2)
attn = _attention(q, k, v, rope, self.heads, mask)
x = x + self.proj["o"](attn) * gate_a[:, None]
y = self.norm2(x) * (1 + scale_f[:, None]) + shift_f[:, None]
hidden, gate = self.proj["ffn_up_gate"](y).chunk(2, -1)
return x + self.proj["ffn_down"](_situ(hidden, gate)) * gate_f[:, None]
attn = _attention(q, k, v, rope, self.heads, mask, ref_span, kv_capture, kv_cache)
x = x + gate(self.proj["o"](attn), 2)
y = affine(self.norm2(x), 4, 3)
hidden, ffn_gate = self.proj["ffn_up_gate"](y).chunk(2, -1)
return x + gate(self.proj["ffn_down"](_situ(hidden, ffn_gate)), 5)
class MossImageTransformer(nn.Module, OstrisModelMixin):
@@ -219,30 +266,83 @@ class MossImageTransformer(nn.Module, OstrisModelMixin):
return checkpoint(module, *args, use_reentrant=False)
return module(*args)
def _rope(self, lengths, height, width, text_len, device):
"""(B, H*W + S, head_dim) fp32 rope; coordinates (r, f, h, w): image
tokens at (0, item text length, centered h, centered w), text token i
at (0, i, 0, 0)."""
def _rope_from_coords(self, coords):
"""(B, L, 4) fp32 (r, f, h, w) coordinates -> (B, L, head_dim) rope."""
device = coords.device
bands = self._bands.get(device)
if bands is None:
bands = _rope_bands(
device, self.params.dim // self.params.num_heads, self.params.rope_base
)
self._bands[device] = bands
batch = lengths.shape[0]
phase = coords.unsqueeze(-1) * bands
return torch.cat((phase.sin(), phase.cos()), -2).flatten(-2)
@staticmethod
def _image_coords(index, frame, height, width, device):
"""(H*W, 4) coordinates of one latent grid: (index, frame, centered h,
centered w). The vendor's image has index 0; references use 1, 2, ..."""
h, w = torch.meshgrid(
torch.arange(height, device=device, dtype=torch.float32),
torch.arange(width, device=device, dtype=torch.float32),
indexing="ij",
)
image = torch.zeros(batch, height * width, 4, device=device)
coords = torch.zeros(height * width, 4, device=device)
coords[:, 0] = index
coords[:, 1] = frame
coords[:, 2] = (h - (height - 1) / 2).flatten()
coords[:, 3] = (w - (width - 1) / 2).flatten()
return coords
def _rope(self, lengths, height, width, text_len, device):
"""(B, H*W + S, head_dim) fp32 rope; coordinates (r, f, h, w): image
tokens at (0, item text length, centered h, centered w), text token i
at (0, i, 0, 0)."""
batch = lengths.shape[0]
image = self._image_coords(0, 0, height, width, device)
image = image.unsqueeze(0).repeat(batch, 1, 1)
image[..., 1] = lengths.float()[:, None]
image[..., 2] = (h - (height - 1) / 2).flatten()
image[..., 3] = (w - (width - 1) / 2).flatten()
text = torch.zeros(batch, text_len, 4, device=device)
text[..., 1] = torch.arange(text_len, device=device, dtype=torch.float32)
phase = torch.cat((image, text), 1).unsqueeze(-1) * bands
return torch.cat((phase.sin(), phase.cos()), -2).flatten(-2)
return self._rope_from_coords(torch.cat((image, text), 1))
def _pack_refs(self, ref_latents, lengths, device, dtype):
"""Per-sample lists of clean (C, h, w) reference latents -> right-padded
tokens (B, R, C), rope (B, R, head_dim) and valid mask (B, R).
Reference i sits on RoPE axis r = i + 1 (always 0 for the vendor's
image and text) with the image's frame coordinate and its own centered
grid."""
tokens, coords = [], []
for refs, length in zip(ref_latents, lengths.tolist()):
sample_tokens, sample_coords = [], []
for i, latent in enumerate(refs):
_, h, w = latent.shape
sample_tokens.append(latent.flatten(1).transpose(0, 1))
sample_coords.append(self._image_coords(i + 1, length, h, w, device))
if sample_tokens:
tokens.append(torch.cat(sample_tokens).to(device, dtype))
coords.append(torch.cat(sample_coords))
else:
tokens.append(torch.zeros(0, self.params.latent_channels, device=device, dtype=dtype))
coords.append(torch.zeros(0, 4, device=device))
num_ref = max(t.shape[0] for t in tokens)
valid = torch.stack(
[torch.arange(num_ref, device=device) < t.shape[0] for t in tokens]
)
tokens = torch.stack([F.pad(t, (0, 0, 0, num_ref - t.shape[0])) for t in tokens])
coords = torch.stack([F.pad(c, (0, 0, 0, num_ref - c.shape[0])) for c in coords])
return tokens, self._rope_from_coords(coords), valid
def _time_modulation(self, times, dtype):
half = self.params.freq_dim // 2
freq = 10000 ** (
-torch.arange(half, device=times.device, dtype=torch.float64) / half
)
phase = times.double()[:, None] * freq
embedding = torch.cat((phase.cos(), phase.sin()), -1).float()
time = self.time_embedding(embedding.to(dtype)).float()
mod = self.shared_block_modulation(time.to(dtype)).view(times.shape[0], 6, -1)
return mod, time
def forward(
self,
@@ -251,8 +351,23 @@ class MossImageTransformer(nn.Module, OstrisModelMixin):
encoder_hidden_states: torch.Tensor, # (B, S, text_dim)
encoder_attention_mask: Optional[torch.Tensor] = None, # (B, S) bool
skip_layers: Optional[Iterable[int]] = None,
ref_latents: Optional[List[List[torch.Tensor]]] = None, # per-sample (C, h, w)
isolate_refs: bool = False,
ref_kv_cache: Optional[dict] = None,
):
"""``ref_latents``: clean reference latents appended after the text at
t=0 (edit LoRAs; the base model never saw them). ``isolate_refs``:
reference queries attend only to reference keys, so their K/V do not
depend on the step and can be cached. ``ref_kv_cache`` (inference,
needs ``isolate_refs``): an empty dict is filled with every block's
reference K/V on this call; once filled, later calls skip the
reference tokens and attend to the cached K/V instead. One cache per
prompt / skip-layer combination (the reference RoPE follows the text
length)."""
skip = frozenset(skip_layers or ())
if ref_kv_cache is not None and not isolate_refs:
raise ValueError("ref_kv_cache needs isolate_refs: cached K/V are only step-invariant when references attend to themselves")
reuse_refs = ref_kv_cache is not None and ref_kv_cache.get("kv") is not None
batch, _, height, width = hidden_states.shape
num_image = height * width
device = hidden_states.device
@@ -279,36 +394,74 @@ class MossImageTransformer(nn.Module, OstrisModelMixin):
times = torch.as_tensor(timestep, device=device, dtype=torch.float32)
times = times.reshape(-1).expand(batch)
half = self.params.freq_dim // 2
freq = 10000 ** (
-torch.arange(half, device=device, dtype=torch.float64) / half
)
phase = times.double()[:, None] * freq
embedding = torch.cat((phase.cos(), phase.sin()), -1).float()
time = self.time_embedding(embedding.to(dtype)).float()
mod = self.shared_block_modulation(time.to(dtype)).view(batch, 6, -1)
mod, time = self._time_modulation(times, dtype)
head = self.video_head_modulation(time.to(dtype)).view(batch, 2, -1)
rope = self._rope(lengths, height, width, text_len, device).to(dtype)
num_live = num_image + text_len
text_mask = joint_mask = None
num_ref, ref_valid, modulation = 0, None, mod
if reuse_refs:
ref_valid = ref_kv_cache["mask"]
elif ref_latents is not None and any(len(r) > 0 for r in ref_latents):
ref_tokens, ref_rope, ref_valid = self._pack_refs(
ref_latents, lengths, device, dtype
)
num_ref = ref_tokens.shape[1]
ref_mod, _ = self._time_modulation(torch.zeros_like(times), dtype)
modulation = (mod, ref_mod, num_live)
rope = torch.cat((rope, ref_rope.to(dtype)), 1)
text_mask = None
if padded:
text_mask = valid[:, None, None, :]
joint_mask = torch.cat(
(torch.ones(batch, num_image, dtype=torch.bool, device=device), valid), 1
)[:, None, None, :]
live_valid = torch.cat(
(torch.ones(batch, num_image, dtype=torch.bool, device=device), valid), 1
)
joint_mask = None
if num_ref and isolate_refs:
key_valid = torch.cat((live_valid, ref_valid), 1)
is_ref = torch.arange(num_live + num_ref, device=device) >= num_live
allowed = key_valid[:, None, :] & (~is_ref[:, None] | is_ref[None, :])[None]
# every query keeps at least itself (padded rows would be all-masked)
allowed = allowed | torch.eye(num_live + num_ref, dtype=torch.bool, device=device)
joint_mask = allowed[:, None]
elif num_ref or reuse_refs:
# live queries see every real reference key (cached ones included)
key_valid = torch.cat((live_valid, ref_valid), 1)
if not bool(key_valid.all()):
joint_mask = key_valid[:, None, None, :]
elif padded:
joint_mask = live_valid[:, None, None, :]
video = hidden_states.flatten(2).transpose(1, 2).to(dtype)
video = self.video_embedder(video.contiguous())
text = self.text_embedder.proj(context.to(dtype))
text_rope = rope[:, num_image:]
text_rope = rope[:, num_image:num_live]
for block in self.text_embedder.blocks:
text = self._ckpt(block, text, text_rope, text_mask)
x = torch.cat((video, text), 1)
parts = [video, text]
if num_ref:
parts.append(self.video_embedder(ref_tokens))
x = torch.cat(parts, 1)
capture = ref_kv_cache is not None and not reuse_refs and num_ref > 0
captured = {}
for index, layer in enumerate(self.layers):
if index not in skip:
x = self._ckpt(layer, x, rope, mod, joint_mask)
if index in skip:
continue
if capture:
slot = []
x = layer(x, rope, modulation, joint_mask, (num_live, num_live + num_ref), slot)
captured[index] = slot[0]
elif reuse_refs:
x = layer(x, rope, modulation, joint_mask, kv_cache=ref_kv_cache["kv"][index])
else:
x = self._ckpt(layer, x, rope, modulation, joint_mask)
if capture:
ref_kv_cache["kv"] = captured
ref_kv_cache["mask"] = ref_valid
x = self.final_norm_video(x[:, :num_image])
x = x * (1 + head[:, 1, None]) + head[:, 0, None]
@@ -1841,6 +1841,59 @@ export const AI_TOOLKIT_UI_MODELS: ModelArch[] = [
disableSections: ["network.conv"],
additionalSections: ["model.layer_offloading", "model.low_vram"],
},
{
name: "moss_image:base_edit",
label: "MOSS-Image Base [Edit Training]",
generateNameOverride: "MOSS-Image Base Edit",
group: "experimental",
defaults: {
// default updates when [selected, unselected] in the UI
"config.process[0].model.name_or_path": [
"OpenMOSS-Team/MOSS-Image-Base",
defaultNameOrPath,
],
"config.process[0].model.quantize": [true, false],
"config.process[0].model.qtype": ["convrot8", "qfloat8"],
// Qwen3.5-9B text encoder + vision tower (references ride in the prompt)
"config.process[0].model.quantize_te": [true, false],
"config.process[0].model.qtype_te": ["convrot8", "qfloat8"],
"config.process[0].model.low_vram": [true, false],
// prompt embeds depend on the control images, so the TE stays loaded
"config.process[0].train.unload_text_encoder": [false, false],
"config.process[0].sample.sampler": ["flowmatch", "flowmatch"],
"config.process[0].train.noise_scheduler": ["flowmatch", "flowmatch"],
"config.process[0].sample.width": [1024, 1024],
"config.process[0].sample.height": [1024, 1024],
"config.process[0].sample.guidance_scale": [3, 4],
"config.process[0].sample.sample_steps": [30, 25],
"config.process[0].train.timestep_type": ["shift", "sigmoid"],
// edit: references as clean t=0 latents + in the prompt; kv_cache:
// references attend only to themselves, so sampling computes their K/V
// once (must be trained that way)
"config.process[0].model.model_kwargs": [
{
edit: true,
kv_cache: true,
match_target_res: true,
uncond_skip_layers: [22],
},
{},
],
"config.process[0].network.network_kwargs.ignore_if_contains": [
["layers.22."],
[],
],
},
disableSections: ["network.conv", "train.unload_text_encoder"],
additionalSections: [
"datasets.multi_control_paths",
"sample.multi_ctrl_imgs",
"model.low_vram",
"model.layer_offloading",
"model.qie.match_target_res",
"model.model_kwargs.kv_cache",
],
},
{
name: "flux2_klein_4b",
label: "FLUX.2-klein-base-4B",