Added initial support for fine tuning and captioning with Qwen 3.8 27B

This commit is contained in:
Jaret Burkett committed 2026-10-18 12:25:28 -06:00
1 parent 8cf43dd2cc
commit e72468b456
17 files changed
+2770 -12

No files matched your search

Whitespace-only changes.
@@ -0,0 +1,164 @@
"""Qwen3.8-27B image / video captioner on the qwen38 arch from
extensions_built_in/llm_models: ComfyUI W4A8 weights kept in their quantization,
custom DeltaNet + attention stack, CUDA-graph decode and optional MTP speculative
decoding. LoRAs trained with the qwen38 arch attach as sidechains (BaseCaptioner).
Text only in the output; audio files are not captioned (use Qwen2.5/3-Omni)."""
from collections import OrderedDict
import concurrent.futures
import os
import traceback
from collections import deque
import torch
from .BaseCaptioner import BaseCaptioner, CaptionConfig
import logging
import warnings
warnings.filterwarnings("ignore")
logging.disable(logging.WARNING)
IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "bmp", "webp"}
# reasoning budget on top of max_new_tokens when thinking is on
MAX_THINKING_TOKENS = 4096
class Qwen38CaptionConfig(CaptionConfig):
def __init__(self, **kwargs):
super().__init__(**kwargs)
# MTP speculative decoding (draft depth tokens per step, verified in one pass)
self.mtp = bool(kwargs.get("mtp", True))
self.mtp_depth = int(kwargs.get("mtp_depth", 3))
# capture the decode step as a CUDA graph (off = eager, for debugging)
self.cuda_graph = bool(kwargs.get("cuda_graph", True))
# vision tower on dequantized bf16 weights (int8 activations drift image embeddings ~6%)
self.vision_bf16 = bool(kwargs.get("vision_bf16", True))
# frames per second sampled from videos
self.video_fps = float(kwargs.get("video_fps", 2))
self.system_prompt = kwargs.get("system_prompt", None) or None
class Qwen38Captioner(BaseCaptioner):
caption_config_class = Qwen38CaptionConfig
def __init__(self, process_id: int, job, config: OrderedDict, **kwargs):
super(Qwen38Captioner, self).__init__(process_id, job, config, **kwargs)
self.llm = None
def load_model(self):
from toolkit.config_modules import ModelConfig
from extensions_built_in.llm_models.qwen38 import Qwen38LLM
cc = self.caption_config
if cc.low_vram:
print("[AITK] low_vram has no effect for Qwen3.8 (W4A8 weights are ~17 GB and stay resident).")
model_config = ModelConfig(
arch="qwen38",
name_or_path=cc.model_name_or_path,
quantize=cc.quantize,
qtype=cc.qtype,
model_kwargs={
"instruction": cc.caption_prompt,
"system_prompt": cc.system_prompt,
"enable_thinking": cc.thinking,
"max_pixels": cc.max_res * cc.max_res,
"max_new_tokens": cc.max_new_tokens + (MAX_THINKING_TOKENS if cc.thinking else 0),
"mtp_depth": cc.mtp_depth if cc.mtp else 0,
"cuda_graph": cc.cuda_graph,
"vision_bf16": cc.vision_bf16,
"video_fps": cc.video_fps,
},
)
llm = Qwen38LLM(self.device_torch, model_config, dtype=cc.dtype)
llm.add_status_update_hook(lambda s: self.update_status("running", s))
llm.load_model()
self.llm = llm
# LoRA sidechains and the stop/compile machinery key off self.model
self.model = llm.model
self.processor = llm.processor
mode = f"MTP depth {cc.mtp_depth}" if cc.mtp else "plain decode"
print(f"[AITK] Qwen3.8 ready: {mode}, cuda graphs {'on' if cc.cuda_graph else 'off'}")
def maybe_compile_models(self):
# the decode step is already a captured CUDA graph; torch.compile would only
# re-trace the custom stack
return
@staticmethod
def _is_image_file(file_path: str) -> bool:
return os.path.splitext(file_path)[1].lower().lstrip(".") in IMAGE_EXTENSIONS
def _prep_media(self, file_path: str) -> dict:
"""CPU side of one file (image decode / frame extraction, processor); runs in a
worker thread ahead of the GPU."""
item = self.llm.prepare_caption_inputs(file_path, self.caption_config.caption_prompt)
item["file"] = file_path
return item
def _generate(self, item: dict) -> str:
out_ids = self.llm.generate_from_inputs(item)
return self.llm.decode_text(out_ids)
def run_caption_loop(self):
"""Worker threads preprocess files ahead of the GPU; generation runs one file
at a time on the main thread (the decode step is weight-bandwidth bound and
CUDA-graphed, so there is no CPU slack to hide)."""
import tqdm as tqdm_mod
pbar = tqdm_mod.tqdm(total=len(self.file_paths), desc="Captioning files", unit="file", smoothing=0.9)
def finish(file_path, caption):
if caption is not None:
self.save_caption_for_file(file_path, caption)
self.step_num += 1
self.update_step()
pbar.update(1)
executor = concurrent.futures.ThreadPoolExecutor(max_workers=max(1, int(self.caption_config.num_workers)))
try:
futures = deque()
file_iter = iter(self.file_paths)
lookahead = max(2, int(self.caption_config.num_workers)) + 1
for _ in range(lookahead):
path = next(file_iter, None)
if path is None:
break
futures.append((path, executor.submit(self._prep_media, path)))
while futures:
if self.is_ui_captioner:
self.maybe_stop()
if self.is_stopping:
break
path, fut = futures.popleft()
nxt = next(file_iter, None)
if nxt is not None:
futures.append((nxt, executor.submit(self._prep_media, nxt)))
try:
item = fut.result()
except Exception as e:
print(f"Error preprocessing {path}: {e}")
finish(path, None)
continue
try:
caption = self._generate(item)
st = self.llm.last_generation_stats
if st.get("seconds"):
pbar.set_postfix_str(f"{st['tokens'] / st['seconds']:.1f} tok/s, prompt {st['prompt_tokens']}")
except Exception as e:
print(f"Error captioning {path}: {e}")
traceback.print_exc()
caption = None
finish(path, caption)
finally:
executor.shutdown(wait=False, cancel_futures=True)
pbar.close()
def get_caption_for_file(self, file_path: str) -> str:
try:
return self._generate(self._prep_media(file_path))
except Exception as e:
print(f"Error processing {file_path}: {e}")
traceback.print_exc()
return None
+13
View File
@@ -61,6 +61,18 @@ class Qwen25OmniCaptionerExtension(Extension):
return Qwen25OmniCaptioner
class Qwen38CaptionerExtension(Extension):
uid = "Qwen38Captioner"
name = "Qwen 3.8 Captioner"
@classmethod
def get_process(cls):
# import your process class here so it is only loaded when needed and return it
from .Qwen38Captioner import Qwen38Captioner
return Qwen38Captioner
class Ideogram4CaptionerExtension(Extension):
uid = "Ideogram4Captioner"
name = "Ideogram4 Captioner"
@@ -79,5 +91,6 @@ AI_TOOLKIT_EXTENSIONS = [
Qwen3VLCaptionerExtension,
Qwen3OmniCaptionerExtension,
Qwen25OmniCaptionerExtension,
Qwen38CaptionerExtension,
Ideogram4CaptionerExtension,
]
@@ -1,5 +1,7 @@
from .qwen25_omni import Qwen25OmniLLM
from .qwen38 import Qwen38LLM
AI_TOOLKIT_MODELS = [
Qwen25OmniLLM,
Qwen38LLM,
]
+421
View File
@@ -0,0 +1,421 @@
"""Qwen3.8-27B as a trainable text-generation model (arch ``qwen38``).
Image or video in (plus a .txt caption), text out. The frozen vision tower encodes
the media into embeddings [N, H] plus the processor's expanded placeholder span and
the MRoPE grid metadata (cached as the "latent" when latent caching is on). Training
runs the text stack on ``prefix + media span + instruction + assistant header +
caption`` with next-token cross-entropy on the caption (LoRA on the text stack;
towers, embeddings and lm_head stay frozen). ``is_llm`` routes the trainer to
``train_llm_accumulation`` -> ``get_llm_loss``: no noise, scheduler, VAE or prompt
encoding.
Weights: the ComfyUI single file (W4A8 codebook language model, int8 convrot
embeddings / lm_head / MTP / vision) is loaded with its quantization kept as-is
through the toolkit's convrotw4a8 / convrot8 backends, or an HF layout (bf16 shards)
is loaded and quantized to the requested qtype. See src/qwen38_model.py for the
model itself (static caches, CUDA-graph decode, MTP speculative decoding).
Samples: ``ctrl_img`` is the media file, the sample prompt is the instruction; the
generated text is written as a .txt.
model_kwargs:
instruction user-turn text used for every training item (default below)
system_prompt optional system message
enable_thinking keep the model's thinking mode on (default false: direct answers)
max_pixels/min_pixels image and video frame budget for the vision tower
video_fps fps the dataset extracted video frames at (dataset fps)
max_new_tokens sample generation budget (default 512)
mtp_depth MTP speculative draft depth for samples (default 3, 0 = off)
cuda_graph CUDA-graph the decode step (default true)
vision_bf16 run the vision tower with bf16 activations on its dequantized int8
weights (default true; int8 activations drift the image embeddings ~6%)
loss_chunk positions per lm_head chunk in the CE (default 512)
"""
import os
import time
from typing import List, Optional
import numpy as np
import torch
import torch.nn.functional as F
import torch.utils.checkpoint
from transformers import AutoConfig, AutoProcessor
from toolkit.basic import flush
from toolkit.config_modules import GenerateImageConfig, ModelConfig
from toolkit.dto import DTO
from toolkit.models.base_model import BaseModel
from toolkit.models.v2.resolver import resolve_component_file
from toolkit.print import print_acc
from .src.qwen38_model import Qwen38Generator, build_sequence_embeds
BASE_REPO = "Qwen/Qwen3.8-27B"
DEFAULT_CHECKPOINT = "Comfy-Org/Qwen3.8-27B/text_encoders/qwen3.8_27b_w4a8.safetensors"
DEFAULT_INSTRUCTION = "Describe this in detail."
KIND_IMAGE, KIND_VIDEO = 1, 2
VIDEO_EXTS = (".mp4", ".avi", ".mov", ".webm", ".mkv", ".wmv", ".m4v", ".flv")
AUDIO_EXTS = (".mp3", ".wav", ".flac", ".aac", ".ogg", ".m4a")
def _media_kind(path: str) -> int:
ext = os.path.splitext(path)[1].lower()
if ext in AUDIO_EXTS:
raise ValueError(f"qwen38 takes images and videos, not audio: {path}")
return KIND_VIDEO if ext in VIDEO_EXTS else KIND_IMAGE
class Qwen38LLM(BaseModel):
arch = "qwen38"
is_llm = True
# the data loader accepts image and video files in one dataset
is_multimodal_llm = True
sample_rate = 16000
def __init__(self, device, model_config: ModelConfig, dtype="bf16", custom_pipeline=None, noise_scheduler=None, **kwargs):
super().__init__(device, model_config, dtype, custom_pipeline, noise_scheduler, **kwargs)
self.is_transformer = True
# LoRA goes on the text stack only (vision tower, embeddings and lm_head stay frozen)
self.target_lora_modules = ["Qwen38TextModel"]
kw = self.model_config.model_kwargs
self.instruction = str(kw.get("instruction", DEFAULT_INSTRUCTION))
self.system_prompt: Optional[str] = kw.get("system_prompt", None)
self.enable_thinking = bool(kw.get("enable_thinking", False))
self.max_pixels = int(kw.get("max_pixels", 1024 * 1024))
self.min_pixels = int(kw.get("min_pixels", 64 * 32 * 32))
self.video_fps = float(kw.get("video_fps", 24))
self.max_new_tokens = int(kw.get("max_new_tokens", 512))
self.mtp_depth = int(kw.get("mtp_depth", 3))
self.cuda_graph = bool(kw.get("cuda_graph", True))
self.vision_bf16 = bool(kw.get("vision_bf16", True))
self.loss_chunk = int(kw.get("loss_chunk", 512))
self.debug = bool(kw.get("debug", False))
self.processor = None
self.additional_loss_logs = {}
@staticmethod
def get_train_scheduler():
# no diffusion: the trainer never samples timesteps for this model
return None
# ------------------------------------------------------------------
# loading
# ------------------------------------------------------------------
def _config_source(self) -> str:
extras = self.model_config.extras_name_or_path
if extras and extras != self.model_config.name_or_path and not extras.endswith(".safetensors"):
return extras
return BASE_REPO
def load_model(self):
dtype = self.torch_dtype
device = self.device_torch
name_or_path = self.model_config.name_or_path or DEFAULT_CHECKPOINT
cfg_src = self._config_source()
want_quant = self.model_config.quantize
qtype = self.model_config.qtype
if self.model_config.layer_offloading and self.model_config.layer_offloading_transformer_percent > 0:
raise NotImplementedError("Layer offloading is not implemented for qwen38")
from .src.qwen38_load import dequantize_vision_tower, load_qwen38
if name_or_path.endswith(".safetensors"):
ckpt = resolve_component_file(name_or_path, folder="text_encoders", component="qwen3.8")
self.print_and_status_update(f"Loading Qwen3.8 from {os.path.basename(ckpt)}")
source = ckpt
else:
self.print_and_status_update(f"Loading Qwen3.8 from {name_or_path}")
source = name_or_path
if os.path.isdir(name_or_path) or "/" in name_or_path:
cfg_src = name_or_path if self.model_config.extras_name_or_path in (None, "", name_or_path) else cfg_src
config = AutoConfig.from_pretrained(cfg_src)
model, prequantized = load_qwen38(source, config, dtype, device, status_fn=self.print_and_status_update)
if prequantized:
model.aitk_is_quantized = True
if want_quant and qtype not in ("convrotw4a8", "convrot8"):
print_acc(f"Checkpoint is pre-quantized (W4A8 / int8 convrot); keeping it instead of {qtype}")
want_quant = False
if self.vision_bf16 and prequantized:
n = dequantize_vision_tower(model, dtype)
print_acc(f"Vision tower: {n} int8 linears dequantized to {dtype} (model_kwargs.vision_bf16)")
model.eval()
model.requires_grad_(False)
if want_quant:
from toolkit.util.quantize import get_qtype, quantize
self.print_and_status_update(f"Quantizing Qwen3.8 ({qtype})")
quantize(model, weights=get_qtype(qtype))
model.aitk_is_quantized = True
flush()
self.model = model
self.model.generator = Qwen38Generator(model, use_cuda_graph=self.cuda_graph)
self.processor = AutoProcessor.from_pretrained(cfg_src)
self.tokenizer = self.processor.tokenizer
self.text_encoder = None
self.vae = None
self.pipeline = self
self._build_template()
flush()
def _template_kwargs(self):
return {"enable_thinking": self.enable_thinking}
def _build_template(self):
"""Split the chat template once around the media span and the instruction so
training sequences can be assembled from cached media spans without the processor."""
mark = "<<AITK_INSTRUCTION>>"
msgs = []
if self.system_prompt is not None:
msgs.append({"role": "system", "content": self.system_prompt})
msgs.append({"role": "user", "content": [{"type": "image", "image": "x"}, {"type": "text", "text": mark}]})
text = self.processor.apply_chat_template(msgs, add_generation_prompt=True, tokenize=False, **self._template_kwargs())
span = "<|vision_start|><|image_pad|><|vision_end|>"
if span not in text or mark not in text:
raise RuntimeError("Unexpected Qwen3.8 chat template; cannot locate the media span")
before, rest = text.split(span, 1)
mid, after = rest.split(mark, 1)
enc = lambda t: self.tokenizer(t, add_special_tokens=False).input_ids
self._prefix_ids = enc(before)
self._mid_ids = enc(mid)
self._after_ids = enc(after)
self._probe = {
KIND_IMAGE: text.replace(mark, "x"),
KIND_VIDEO: text.replace(span, "<|vision_start|><|video_pad|><|vision_end|>").replace(mark, "x"),
}
self._tail_ids = enc(mid + "x" + after)
cfg = self.model.config
self._placeholder = {KIND_IMAGE: cfg.image_token_id, KIND_VIDEO: cfg.video_token_id}
self._im_end = self.tokenizer.convert_tokens_to_ids("<|im_end|>")
self._eos_ids = [self._im_end, self.tokenizer.convert_tokens_to_ids("<|endoftext|>")]
def get_transformer_block_names(self) -> Optional[List[str]]:
return ["model.language_model.layers"]
def get_model_has_grad(self):
return False
def get_te_has_grad(self):
return False
def get_bucket_divisibility(self):
return 32
def save_model(self, output_path, meta, save_dtype):
raise NotImplementedError("qwen38: only LoRA training is supported (no full-model save)")
# LoRA keys are model-relative (model.language_model.layers.N....), the HF layout
def convert_lora_weights_before_save(self, state_dict):
return {(k[len("transformer.") :] if k.startswith("transformer.") else k): v for k, v in state_dict.items()}
def convert_lora_weights_before_load(self, state_dict):
return {(k if k.startswith("transformer.") else "transformer." + k): v for k, v in state_dict.items()}
# ------------------------------------------------------------------
# media encoding (latent cache)
# ------------------------------------------------------------------
def _span(self, input_ids: torch.Tensor, types: torch.Tensor):
"""The processor-expanded media span (tokens between the template prefix and
the instruction tail), with its token-type ids."""
ids = input_ids[0]
n = ids.shape[0]
p, t = len(self._prefix_ids), len(self._tail_ids)
if ids[:p].tolist() != self._prefix_ids or ids[n - t :].tolist() != self._tail_ids:
raise RuntimeError("Processor output does not start/end with the template prefix/tail")
return ids[p : n - t].to(torch.int32), types[0, p : n - t].to(torch.int32)
def _pack(self, embeds, media_ids, media_types, kind, grid):
dev = embeds.device
return DTO(
embeds[None].to(self.torch_dtype),
media_ids=media_ids[None].to(dev),
media_types=media_types[None].to(dev),
kind=torch.tensor([kind], dtype=torch.int32, device=dev),
grid_thw=torch.tensor([list(grid)], dtype=torch.int32, device=dev),
)
@torch.no_grad()
def encode_images(self, image_list: torch.Tensor, device=None, dtype=None):
"""[1, 3, H, W] image in [-1, 1] | [1, F, 3, H, W] frames."""
if image_list.shape[0] != 1:
raise ValueError("qwen38 encodes one item at a time (batch_size 1)")
if self.model.device == torch.device("cpu"):
self.model.to(self.device_torch)
x = image_list[0]
if x.ndim == 2:
raise ValueError("qwen38 does not take audio")
if x.ndim == 3:
return self._encode_image_item(x)
if x.ndim == 4:
return self._encode_video_item(x)
raise ValueError(f"Unexpected media tensor shape {tuple(image_list.shape)}")
def encode_audio(self, audio_data_list):
# the data loader offers a video's own audio track here; video is vision-only
return torch.zeros(len(audio_data_list), 1)
@staticmethod
def _to_uint8(img: torch.Tensor) -> np.ndarray:
return ((img.float().clamp(-1, 1) + 1.0) * 127.5).round().to(torch.uint8).permute(1, 2, 0).cpu().numpy()
def _encode_image_item(self, img: torch.Tensor):
from PIL import Image
pil = Image.fromarray(self._to_uint8(img))
inputs = self.processor(text=self._probe[KIND_IMAGE], images=[pil], return_tensors="pt", min_pixels=self.min_pixels, max_pixels=self.max_pixels)
grid = inputs["image_grid_thw"]
feats = self.model.get_image_features(inputs["pixel_values"].to(self.device_torch), grid.to(self.device_torch))
ids, types = self._span(inputs["input_ids"], inputs["mm_token_type_ids"])
return self._pack(feats, ids, types, KIND_IMAGE, grid[0].tolist())
def _encode_video_item(self, frames: torch.Tensor):
video = np.stack([self._to_uint8(f) for f in frames]) # [F, H, W, 3]
inputs = self.processor(
text=self._probe[KIND_VIDEO], videos=[video], return_tensors="pt", fps=self.video_fps,
min_pixels=self.min_pixels, max_pixels=self.max_pixels,
)
grid = inputs["video_grid_thw"]
feats = self.model.get_image_features(inputs["pixel_values_videos"].to(self.device_torch), grid.to(self.device_torch))
ids, types = self._span(inputs["input_ids"], inputs["mm_token_type_ids"])
return self._pack(feats, ids, types, KIND_VIDEO, grid[0].tolist())
# ------------------------------------------------------------------
# training
# ------------------------------------------------------------------
def _sequence(self, media_ids: torch.Tensor, media_types: torch.Tensor, caption: str):
"""ids [L], token types [L] and the index where the caption (loss span) starts."""
instr = self.tokenizer(self.instruction, add_special_tokens=False).input_ids
target = self.tokenizer(caption, add_special_tokens=False).input_ids + [self._im_end]
head = self._prefix_ids + media_ids.tolist() + self._mid_ids + instr + self._after_ids
ids = torch.tensor(head + target, dtype=torch.long, device=self.device_torch)
types = torch.zeros_like(ids)
p = len(self._prefix_ids)
types[p : p + media_ids.shape[0]] = media_types.to(types.dtype)
return ids, types, len(head)
def _lm_loss(self, ids, types, start: int, media: torch.Tensor, kind: int, grid):
model = self.model
embeds = build_sequence_embeds(model, ids, media, [self._placeholder[kind]])
grid_t = torch.tensor([list(grid)], device=ids.device)
position_ids, _ = model.get_rope_index(
ids, types, image_grid_thw=grid_t if kind == KIND_IMAGE else None, video_grid_thw=grid_t if kind == KIND_VIDEO else None
)
hidden = model.forward_hidden(embeds, position_ids)[0, start - 1 : -1]
targets = ids[start:]
lm_head = model.lm_head
def chunk_ce(h, t):
return F.cross_entropy(lm_head(h).float(), t, reduction="sum")
total = hidden.new_zeros((), dtype=torch.float32)
for s0 in range(0, targets.shape[0], self.loss_chunk):
h, t = hidden[s0 : s0 + self.loss_chunk], targets[s0 : s0 + self.loss_chunk]
if torch.is_grad_enabled():
total = total + torch.utils.checkpoint.checkpoint(chunk_ce, h, t, use_reentrant=False)
else:
total = total + chunk_ce(h, t)
return total / targets.shape[0]
def get_llm_loss(self, batch) -> torch.Tensor:
"""Next-token cross-entropy on the caption, from the cached media embeddings.
Called by the trainer's train_llm_accumulation; logs ride in additional_loss_logs."""
if self.model.device == torch.device("cpu"):
self.model.to(self.device_torch)
lat = batch.latents
if not isinstance(lat, DTO) or lat.get("media_ids") is None:
if batch.tensor is None:
raise ValueError("qwen38 got a batch with neither media tensors nor cached embeddings")
lat = self.encode_images(batch.tensor)
if lat.shape[0] != 1:
raise ValueError("qwen38 trains with batch_size 1")
captions = batch.get_caption_list()
media = lat.tensor[0].to(self.device_torch, self.torch_dtype)
kind = int(lat.get("kind")[0])
ids, types, start = self._sequence(lat.get("media_ids")[0].long(), lat.get("media_types")[0].long(), captions[0])
ce = self._lm_loss(ids, types, start, media, kind, lat.get("grid_thw")[0].tolist())
if self.debug:
print_acc(
f"qwen38: seq {ids.shape[0]} target {ids.shape[0] - start} ce {ce.item():.4f} "
f"peak {torch.cuda.max_memory_allocated() / 1e9:.1f} GB"
)
self.additional_loss_logs = {"loss/ce": ce.detach().float().item()}
return ce
# ------------------------------------------------------------------
# sampling: ctrl_img is the media file, prompt is the instruction, output is text
# ------------------------------------------------------------------
def get_generation_pipeline(self):
return self
def _load_sample_media(self, path: str):
kind = _media_kind(path)
if kind == KIND_IMAGE:
from PIL import Image
return kind, {"type": "image", "image": "x"}, {"images": [Image.open(path).convert("RGB")]}
from transformers.video_utils import load_video
frames = load_video(path, fps=self.video_fps)
if isinstance(frames, tuple):
frames = frames[0]
return kind, {"type": "video", "video": "x"}, {"videos": [frames], "fps": self.video_fps}
def prepare_caption_inputs(self, media_path: Optional[str], instruction: Optional[str] = None) -> dict:
"""CPU half of caption(): media decode + processor. Safe in a worker thread."""
instruction = instruction.strip() if instruction and instruction.strip() else self.instruction
content = []
media_kwargs = {}
kind = None
if media_path:
kind, entry, media_kwargs = self._load_sample_media(media_path)
content.append(entry)
content.append({"type": "text", "text": instruction})
msgs = ([{"role": "system", "content": self.system_prompt}] if self.system_prompt is not None else []) + [{"role": "user", "content": content}]
text = self.processor.apply_chat_template(msgs, add_generation_prompt=True, tokenize=False, **self._template_kwargs())
inputs = self.processor(text=text, return_tensors="pt", min_pixels=self.min_pixels, max_pixels=self.max_pixels, **media_kwargs)
return {"kind": kind, "inputs": inputs}
@torch.no_grad()
def generate_from_inputs(self, prepared: dict, max_new_tokens: Optional[int] = None, mtp_depth: Optional[int] = None) -> List[int]:
"""GPU half of caption(): vision tower, prefill and decode. Returns token ids."""
if self.model.device == torch.device("cpu"):
self.model.to(self.device_torch)
kind, inputs = prepared["kind"], prepared["inputs"]
ids = inputs["input_ids"][0].to(self.device_torch)
types = inputs["mm_token_type_ids"][0].to(self.device_torch)
model = self.model
media = None
image_grid = video_grid = None
if kind == KIND_IMAGE:
image_grid = inputs["image_grid_thw"].to(self.device_torch)
media = model.get_image_features(inputs["pixel_values"].to(self.device_torch), image_grid)
elif kind == KIND_VIDEO:
video_grid = inputs["video_grid_thw"].to(self.device_torch)
media = model.get_image_features(inputs["pixel_values_videos"].to(self.device_torch), video_grid)
embeds = build_sequence_embeds(model, ids, media, [self._placeholder[KIND_IMAGE], self._placeholder[KIND_VIDEO]])
position_ids, delta = model.get_rope_index(ids, types, image_grid_thw=image_grid, video_grid_thw=video_grid)
t0 = time.perf_counter()
out_ids = model.generator.generate(
embeds, ids[None], position_ids, delta,
max_new_tokens=self.max_new_tokens if max_new_tokens is None else max_new_tokens,
eos_ids=self._eos_ids, mtp_depth=self.mtp_depth if mtp_depth is None else mtp_depth,
)
self.last_generation_stats = dict(model.generator.last_stats, seconds=time.perf_counter() - t0, prompt_tokens=int(ids.shape[0]))
return out_ids
def decode_text(self, out_ids: List[int]) -> str:
text = self.tokenizer.decode(out_ids, skip_special_tokens=True)
# thinking mode: keep only the answer after the think block
if "</think>" in text:
text = text.split("</think>")[-1]
return text.strip()
@torch.no_grad()
def caption(self, media_path: Optional[str], instruction: Optional[str] = None, max_new_tokens: Optional[int] = None, mtp_depth: Optional[int] = None) -> str:
"""Generate text for one media file (or none). Also the sample path."""
return self.decode_text(self.generate_from_inputs(self.prepare_caption_inputs(media_path, instruction), max_new_tokens, mtp_depth))
def generate_single_image(self, pipeline, gen_config: GenerateImageConfig, conditional_embeds, unconditional_embeds, generator, extra):
# a str return is saved as <sample>.txt by GenerateImageConfig.save_image
return self.caption(gen_config.ctrl_img, gen_config.prompt)
@@ -0,0 +1,274 @@
"""Small fused Triton kernels for the Qwen3.8 decode path. Inference only: every op
falls back to the eager formulation under autograd or off the GPU."""
import torch
_kernels = None
def _get():
global _kernels
if _kernels is not None:
return _kernels
import triton
import triton.language as tl
@triton.jit
def rmsnorm_kernel(x_ptr, w_ptr, o_ptr, D, eps, ADD_ONE: tl.constexpr, BLOCK: tl.constexpr):
# one row per program: fp32 math, out = x * rsqrt(mean(x^2) + eps) * ((1 +) w)
row = tl.program_id(0)
offs = tl.arange(0, BLOCK)
mask = offs < D
x = tl.load(x_ptr + row * D + offs, mask=mask, other=0.0).to(tl.float32)
var = tl.sum(x * x, axis=0) / D
w = tl.load(w_ptr + offs, mask=mask, other=0.0).to(tl.float32)
if ADD_ONE:
w = w + 1.0
y = x * (1.0 / tl.sqrt(var + eps)) * w
tl.store(o_ptr + row * D + offs, y.to(o_ptr.dtype.element_ty), mask=mask)
@triton.jit
def decode_attn_kernel(
q_ptr, k_ptr, v_ptr, qpos_ptr, o_ptr,
L, scale, min_key,
stride_qb, stride_qh, stride_qm,
stride_kb, stride_kh, stride_kl,
stride_ob, stride_oh, stride_om,
G: tl.constexpr, M: tl.constexpr, MPAD: tl.constexpr, ROWS: tl.constexpr, D: tl.constexpr, BLOCK_L: tl.constexpr,
):
# one program per (batch, kv head): the G query heads of the group x M query
# tokens (rows padded to ROWS >= 16 for the MMA) attend the cache up to the
# largest query position, read from a device tensor so a CUDA graph never bakes a
# length in. Online softmax in fp32; scores in fp32 straight from the bf16 dot.
b = tl.program_id(0)
hk = tl.program_id(1)
rows = tl.arange(0, ROWS)
rmask = rows < G * M
g_idx = tl.where(rmask, rows // M, 0)
m_idx = tl.where(rmask, rows % M, 0)
offs_d = tl.arange(0, D)
qpos = tl.load(qpos_ptr + m_idx)
offs_mp = tl.arange(0, MPAD)
max_pos = tl.max(tl.load(qpos_ptr + offs_mp, mask=offs_mp < M, other=0), axis=0)
q = tl.load(
q_ptr + b * stride_qb + (hk * G + g_idx)[:, None] * stride_qh + m_idx[:, None] * stride_qm + offs_d[None, :],
mask=rmask[:, None], other=0.0,
) # [ROWS, D] bf16
m_i = tl.full((ROWS,), float("-inf"), tl.float32)
l_i = tl.zeros((ROWS,), tl.float32)
acc = tl.zeros((ROWS, D), tl.float32)
n_blocks = (max_pos + BLOCK_L) // BLOCK_L
for blk in range(0, n_blocks):
offs_l = blk * BLOCK_L + tl.arange(0, BLOCK_L)
kmask = offs_l < L
k = tl.load(k_ptr + b * stride_kb + hk * stride_kh + offs_l[:, None] * stride_kl + offs_d[None, :],
mask=kmask[:, None], other=0.0) # [BLOCK_L, D]
s = tl.dot(q, tl.trans(k), out_dtype=tl.float32) * scale # [ROWS, BLOCK_L]
allowed = (offs_l[None, :] <= qpos[:, None]) & (offs_l[None, :] >= min_key) & kmask[None, :]
s = tl.where(allowed, s, float("-inf"))
m_new = tl.maximum(m_i, tl.max(s, axis=1))
alpha = tl.exp(m_i - m_new)
p = tl.exp(s - m_new[:, None])
l_i = l_i * alpha + tl.sum(p, axis=1)
v = tl.load(v_ptr + b * stride_kb + hk * stride_kh + offs_l[:, None] * stride_kl + offs_d[None, :],
mask=kmask[:, None], other=0.0)
acc = acc * alpha[:, None] + tl.dot(p.to(tl.bfloat16), v, out_dtype=tl.float32)
m_i = m_new
out = acc / l_i[:, None]
tl.store(o_ptr + b * stride_ob + (hk * G + g_idx)[:, None] * stride_oh + m_idx[:, None] * stride_om + offs_d[None, :],
out.to(o_ptr.dtype.element_ty), mask=rmask[:, None])
@triton.jit
def add_rmsnorm_kernel(h_ptr, x_ptr, w_ptr, hout_ptr, y_ptr, D, eps, ADD_ONE: tl.constexpr, BLOCK: tl.constexpr):
# h_new = h + x (bf16, written back) and y = rmsnorm(h_new) in one pass
row = tl.program_id(0)
offs = tl.arange(0, BLOCK)
mask = offs < D
h = tl.load(h_ptr + row * D + offs, mask=mask, other=0.0).to(tl.float32)
x = tl.load(x_ptr + row * D + offs, mask=mask, other=0.0).to(tl.float32)
hn = (h + x).to(hout_ptr.dtype.element_ty)
tl.store(hout_ptr + row * D + offs, hn, mask=mask)
hf = hn.to(tl.float32)
var = tl.sum(hf * hf, axis=0) / D
w = tl.load(w_ptr + offs, mask=mask, other=0.0).to(tl.float32)
if ADD_ONE:
w = w + 1.0
tl.store(y_ptr + row * D + offs, (hf * (1.0 / tl.sqrt(var + eps)) * w).to(y_ptr.dtype.element_ty), mask=mask)
@triton.jit
def swiglu_kernel(g_ptr, u_ptr, o_ptr, n, BLOCK: tl.constexpr):
pid = tl.program_id(0)
offs = pid * BLOCK + tl.arange(0, BLOCK)
mask = offs < n
g = tl.load(g_ptr + offs, mask=mask, other=0.0).to(tl.float32)
u = tl.load(u_ptr + offs, mask=mask, other=0.0).to(tl.float32)
tl.store(o_ptr + offs, (g * tl.sigmoid(g) * u).to(o_ptr.dtype.element_ty), mask=mask)
@triton.jit
def _conv_in(state_ptr, x_ptr, b, offs_c, mask, C, i: tl.constexpr, M: tl.constexpr, KS: tl.constexpr):
# input i of the window [state (KS-1 past inputs) | M new inputs] for channels offs_c
if i < KS - 1:
return tl.load(state_ptr + b * C * (KS - 1) + offs_c * (KS - 1) + i, mask=mask, other=0.0).to(tl.float32)
else:
return tl.load(x_ptr + (b * M + (i - (KS - 1))) * C + offs_c, mask=mask, other=0.0).to(tl.float32)
@triton.jit
def conv_step_kernel(state_ptr, x_ptr, w_ptr, o_ptr, C, M: tl.constexpr, KS: tl.constexpr, WRITE_STATE: tl.constexpr, BLOCK_C: tl.constexpr):
# depthwise causal conv (KS taps) over [state | M new inputs] + silu, x and out token
# major [B, M, C] (as the projection produced them), state [B, C, KS-1].
# Optionally slides the state window forward by M.
b = tl.program_id(0)
offs_c = tl.program_id(1) * BLOCK_C + tl.arange(0, BLOCK_C)
mask = offs_c < C
for m in tl.static_range(M):
acc = tl.zeros((BLOCK_C,), tl.float32)
for j in tl.static_range(KS):
w = tl.load(w_ptr + offs_c * KS + j, mask=mask, other=0.0).to(tl.float32)
acc += w * _conv_in(state_ptr, x_ptr, b, offs_c, mask, C, m + j, M, KS)
tl.store(o_ptr + (b * M + m) * C + offs_c, (acc * tl.sigmoid(acc)).to(o_ptr.dtype.element_ty), mask=mask)
if WRITE_STATE:
for j in tl.static_range(KS - 1):
v = _conv_in(state_ptr, x_ptr, b, offs_c, mask, C, M + j, M, KS)
tl.store(state_ptr + b * C * (KS - 1) + offs_c * (KS - 1) + j, v.to(state_ptr.dtype.element_ty), mask=mask)
_kernels = (rmsnorm_kernel, decode_attn_kernel, add_rmsnorm_kernel, swiglu_kernel, conv_step_kernel)
return _kernels
@torch.library.custom_op("ostris::qwen38_rmsnorm", mutates_args=())
def _rmsnorm_op(x: torch.Tensor, weight: torch.Tensor, eps: float, add_one: bool) -> torch.Tensor:
shape = x.shape
d = shape[-1]
x2 = x.reshape(-1, d)
if not x2.is_contiguous():
x2 = x2.contiguous()
out = torch.empty_like(x2)
kernel = _get()[0]
block = 1 << (d - 1).bit_length()
kernel[(x2.shape[0],)](x2, weight, out, d, eps, ADD_ONE=add_one, BLOCK=block, num_warps=8 if block >= 2048 else 2)
return out.view(shape)
@_rmsnorm_op.register_fake
def _rmsnorm_fake(x, weight, eps, add_one):
return torch.empty_like(x)
def rmsnorm(x: torch.Tensor, weight: torch.Tensor, eps: float, add_one: bool = True) -> torch.Tensor:
"""Fused RMSNorm when no gradient is needed; eager fp32 math otherwise."""
if x.is_cuda and not torch.is_grad_enabled() and x.shape[-1] <= 16384:
return _rmsnorm_op(x, weight, eps, add_one)
xf = x.float()
w = weight.float()
if add_one:
w = 1.0 + w
return (xf * torch.rsqrt(xf.pow(2).mean(-1, keepdim=True) + eps) * w).type_as(x)
@torch.library.custom_op("ostris::qwen38_decode_attn", mutates_args=())
def _decode_attn_op(q: torch.Tensor, k_cache: torch.Tensor, v_cache: torch.Tensor, qpos: torch.Tensor, scale: float, min_key: int) -> torch.Tensor:
"""q [B, Hq, M, D] (M power of two <= 16), caches [B, Hk, L, D], qpos [M] int64.
Reads the cache only up to the largest query position."""
b, hq, m, d = q.shape
hk, L = k_cache.shape[1], k_cache.shape[2]
g = hq // hk
q = q.contiguous()
out = torch.empty_like(q)
kernel = _get()[1]
rows = max(16, 1 << (g * m - 1).bit_length())
kernel[(b, hk)](
q, k_cache, v_cache, qpos, out,
L, scale, min_key,
q.stride(0), q.stride(1), q.stride(2),
k_cache.stride(0), k_cache.stride(1), k_cache.stride(2),
out.stride(0), out.stride(1), out.stride(2),
G=g, M=m, MPAD=1 << max(0, (m - 1).bit_length()), ROWS=rows, D=d, BLOCK_L=32, num_warps=4, num_stages=2,
)
return out
@_decode_attn_op.register_fake
def _decode_attn_fake(q, k_cache, v_cache, qpos, scale, min_key):
return torch.empty_like(q)
# off by default: one program per kv head leaves most SMs idle, so past ~500 keys the
# cuBLAS path (full-cache masked bmm) is faster; a split-key version would fix that.
DECODE_ATTN_FUSED = False
def decode_attention_fused_ok(q: torch.Tensor, k_cache: torch.Tensor) -> bool:
b, hq, m, d = q.shape
hk = k_cache.shape[1]
return DECODE_ATTN_FUSED and q.is_cuda and m <= 16 and (hq // hk) * m <= 128 and d in (64, 128, 256) and not torch.is_grad_enabled()
@torch.library.custom_op("ostris::qwen38_add_rmsnorm", mutates_args=())
def _add_rmsnorm_op(h: torch.Tensor, x: torch.Tensor, weight: torch.Tensor, eps: float, add_one: bool) -> list[torch.Tensor]:
shape = h.shape
d = shape[-1]
h2 = h.reshape(-1, d).contiguous()
x2 = x.reshape(-1, d).contiguous()
hout = torch.empty_like(h2)
y = torch.empty_like(h2)
kernel = _get()[2]
block = 1 << (d - 1).bit_length()
kernel[(h2.shape[0],)](h2, x2, weight, hout, y, d, eps, ADD_ONE=add_one, BLOCK=block, num_warps=8 if block >= 2048 else 2)
return [hout.view(shape), y.view(shape)]
@_add_rmsnorm_op.register_fake
def _add_rmsnorm_fake(h, x, weight, eps, add_one):
return [torch.empty_like(h), torch.empty_like(h)]
def add_rmsnorm(h: torch.Tensor, x: torch.Tensor, weight: torch.Tensor, eps: float, add_one: bool = True):
"""(h + x, rmsnorm(h + x)): fused on the no-grad GPU path."""
if h.is_cuda and not torch.is_grad_enabled() and h.shape[-1] <= 16384 and h.dtype == x.dtype:
hn, y = _add_rmsnorm_op(h, x, weight, eps, add_one)
return hn, y
hn = h + x
return hn, rmsnorm(hn, weight, eps, add_one)
@torch.library.custom_op("ostris::qwen38_swiglu", mutates_args=())
def _swiglu_op(g: torch.Tensor, u: torch.Tensor) -> torch.Tensor:
g2 = g.contiguous()
u2 = u.contiguous()
out = torch.empty_like(g2)
n = g2.numel()
kernel = _get()[3]
kernel[(-(-n // 4096),)](g2, u2, out, n, BLOCK=4096, num_warps=4)
return out
@_swiglu_op.register_fake
def _swiglu_fake(g, u):
return torch.empty_like(g)
def swiglu(g: torch.Tensor, u: torch.Tensor) -> torch.Tensor:
if g.is_cuda and not torch.is_grad_enabled() and g.dtype == u.dtype:
return _swiglu_op(g, u)
return torch.nn.functional.silu(g) * u
@torch.library.custom_op("ostris::qwen38_conv_step", mutates_args={"state"})
def _conv_step_op(state: torch.Tensor, x: torch.Tensor, weight: torch.Tensor, write_state: bool) -> torch.Tensor:
"""Depthwise causal conv + silu of M new tokens x [B, M, C] against state [B, C, KS-1]
(conv weight [C, 1, KS]); returns [B, M, C]. write_state slides the window forward."""
b, m, c = x.shape
ks = weight.shape[-1]
x2 = x.contiguous()
out = torch.empty_like(x2)
kernel = _get()[4]
block_c = 1024
kernel[(b, -(-c // block_c))](state, x2, weight.reshape(c, ks).contiguous(), out, c, M=m, KS=ks, WRITE_STATE=write_state, BLOCK_C=block_c, num_warps=4)
return out
@_conv_step_op.register_fake
def _conv_step_fake(state, x, weight, write_state):
return torch.empty_like(x)
@@ -0,0 +1,129 @@
"""Build a Qwen38ForConditionalGeneration from a ComfyUI single file (quantized layers
attached to the toolkit backends as-is) or an HF layout (bf16 shards)."""
import json
import os
from typing import Callable, Optional, Tuple
import torch
from safetensors import safe_open
from toolkit.print import print_acc
from .qwen38_model import Qwen38ForConditionalGeneration
def _list_source_files(source: str):
"""(files, is_single_file) for a .safetensors path, a local HF dir or a hub repo id."""
if os.path.isfile(source):
return [source], True
if os.path.isdir(source):
d = source
else:
import huggingface_hub
d = huggingface_hub.snapshot_download(source, allow_patterns=["*.safetensors", "*.json"])
index = os.path.join(d, "model.safetensors.index.json")
if os.path.isfile(index):
files = sorted(set(json.load(open(index))["weight_map"].values()))
return [os.path.join(d, f) for f in files], False
single = os.path.join(d, "model.safetensors")
if os.path.isfile(single):
return [single], False
raise FileNotFoundError(f"No safetensors weights found under {d}")
def _read_keys(files):
keys = set()
for f in files:
with safe_open(f, "pt") as sf:
keys.update(sf.keys())
return keys
def _load_into_device(files, device) -> dict:
sd = {}
dev = str(device)
for f in files:
with safe_open(f, "pt", device=dev) as sf:
for k in sf.keys():
sd[k] = sf.get_tensor(k)
return sd
@torch.no_grad()
def load_qwen38(
source: str,
config,
dtype: torch.dtype,
device,
status_fn: Optional[Callable[[str], None]] = None,
) -> Tuple[Qwen38ForConditionalGeneration, bool]:
"""Returns (model on `device`, is_prequantized). Quantized ComfyUI layers keep
their stored format (convrotw4a8 / convrot8 OstrisLinear, int8 embeddings)."""
from accelerate import init_empty_weights
from toolkit.util.comfy_quant_import import import_comfy_quantized_layers
files, _ = _list_source_files(source)
keys = _read_keys(files)
with_mtp = any(k.startswith("mtp.") for k in keys)
config.vision_config._attn_implementation = "sdpa"
with init_empty_weights(include_buffers=False):
model = Qwen38ForConditionalGeneration(config, with_mtp=with_mtp)
model.eval()
if status_fn is not None:
status_fn(f"Reading {len(files)} weight file(s) onto {device}")
sd = _load_into_device(files, device)
prequantized = any(k.endswith(".comfy_quant") for k in sd)
if prequantized:
sd, n = import_comfy_quantized_layers(model, sd, orig_dtype=dtype, rotated_embedding_rows=True)
print_acc(f"Attached {n} pre-quantized ComfyUI layers")
sd = {k: (v.to(dtype) if v.is_floating_point() else v) for k, v in sd.items()}
result = model.load_state_dict(sd, assign=True, strict=False)
allowed = set()
for name, module in model.named_modules():
if hasattr(module, "ostris_quantizer"):
allowed.add(f"{name}.weight")
allowed.add(f"{name}.bias")
missing = [k for k in result.missing_keys if k not in allowed]
if missing or result.unexpected_keys:
raise RuntimeError(f"Qwen3.8 checkpoint mismatch. missing: {missing[:8]} unexpected: {result.unexpected_keys[:8]}")
leftover = [n for n, p in model.named_parameters() if p.device.type == "meta"]
if leftover:
raise RuntimeError(f"Params never loaded: {leftover[:8]}")
# buffers built in __init__ (rope tables) still live on the cpu
for module in model.modules():
if hasattr(module, "ostris_quantizer"):
continue
for pname, prm in list(module.named_parameters(recurse=False)):
if prm.is_floating_point() and prm.dtype != dtype:
prm.data = prm.data.to(dtype)
model.to(device)
return model, prequantized
@torch.no_grad()
def dequantize_vision_tower(model: Qwen38ForConditionalGeneration, dtype: torch.dtype) -> int:
"""Replace the vision tower's quantized linears with plain Linears holding the
dequantized weights. The stored int8 weights are kept exactly; only the per-token
int8 activation quantization goes away, which measured a 6% drift of the image
embeddings against the bf16 reference. Runs once per image, so the cost is memory
only (~0.4 GB for the 27B tower). Returns the number of layers converted."""
from toolkit.util.ostris_quant import OstrisLinear
visual = model.model.visual
n = 0
for name, mod in list(visual.named_modules()):
if not isinstance(mod, OstrisLinear):
continue
lin = torch.nn.Linear(mod.in_features, mod.out_features, bias=mod.bias is not None, device="meta", dtype=dtype)
lin.weight = torch.nn.Parameter(mod.ostris_quantizer.dequantize(mod).to(dtype), requires_grad=False)
if mod.bias is not None:
lin.bias = torch.nn.Parameter(mod.bias.detach().to(dtype), requires_grad=False)
parent_name, _, attr = name.rpartition(".")
parent = visual.get_submodule(parent_name) if parent_name else visual
setattr(parent, attr, lin)
n += 1
return n
@@ -0,0 +1,808 @@
"""Qwen3.8 (Qwen3.5-family hybrid VLM) built for throughput.
Module tree and parameter names mirror transformers' Qwen3_5ForConditionalGeneration
(model.language_model.layers.N.{linear_attn|self_attn,mlp,...}, model.visual.*,
lm_head, mtp.*) so the ComfyUI / HF checkpoints load 1:1 and LoRA keys stay standard.
The vision tower is transformers' own Qwen3_5VisionModel (runs once per image); the
text stack, caches, decode step and speculative loop are this file's.
Text stack: 3 of every 4 layers are Gated DeltaNet (linear attention, fla kernels:
chunked for prefill / training, fused recurrent for decode), the 4th is gated full
attention (GQA, partial interleaved MRoPE). Linears are whatever the loader attached
(W4A8 codebook / int8 convrot OstrisLinear, or plain nn.Linear).
Decode: static KV + recurrent-state caches with a device-side position, so one
decode step is a fixed graph that CUDA-graph replays. With the MTP draft head the
step is draft D tokens -> verify D+1 in one pass -> accept -> commit states ->
refresh the MTP cache from the verified hidden states.
"""
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.utils.checkpoint
from fla.modules import FusedRMSNormGated
from fla.ops.gated_delta_rule import chunk_gated_delta_rule, fused_recurrent_gated_delta_rule
from transformers.models.qwen3_5.modeling_qwen3_5 import Qwen3_5VisionModel
from toolkit.util.convrot_w4a8_quant import apply_forward_hooks, module_hooks_only, w4a8_grouped_linear
from .qwen38_kernels import _conv_step_op, _decode_attn_op, add_rmsnorm, decode_attention_fused_ok, rmsnorm, swiglu
# ------------------------------------------------------------------ norms / rope
class RMSNorm(nn.Module):
"""Qwen3.5 RMSNorm: fp32 math, (1 + weight) scaling."""
def __init__(self, dim: int, eps: float = 1e-6):
super().__init__()
self.weight = nn.Parameter(torch.zeros(dim))
self.eps = eps
def forward(self, x: torch.Tensor) -> torch.Tensor:
return rmsnorm(x, self.weight, self.eps, add_one=True)
def rotate_half(x):
x1, x2 = x[..., : x.shape[-1] // 2], x[..., x.shape[-1] // 2 :]
return torch.cat((-x2, x1), dim=-1)
def apply_partial_rope(q, k, cos, sin):
"""q [B,H,T,D], k [B,Hk,T,D]; cos/sin [B,T,R] rotate the first R dims."""
cos = cos.unsqueeze(1)
sin = sin.unsqueeze(1)
r = cos.shape[-1]
q_rot, q_pass = q[..., :r], q[..., r:]
k_rot, k_pass = k[..., :r], k[..., r:]
q_rot = q_rot * cos + rotate_half(q_rot) * sin
k_rot = k_rot * cos + rotate_half(k_rot) * sin
return torch.cat([q_rot, q_pass], dim=-1), torch.cat([k_rot, k_pass], dim=-1)
class InterleavedMRope(nn.Module):
"""Qwen3.5 interleaved MRoPE for the partial rotary dims: frequency i takes its
position from axis i % 3 (T, H, W). Text tokens have equal positions on all axes,
so decode uses a 1-D table."""
def __init__(self, rotary_dim: int, theta: float, mrope_section):
super().__init__()
half = rotary_dim // 2
inv_freq = 1.0 / (theta ** (torch.arange(0, rotary_dim, 2, dtype=torch.int64).float() / rotary_dim))
self.register_buffer("inv_freq", inv_freq, persistent=False)
# axis per frequency: [T,H,W] interleaved as in apply_interleaved_mrope
axis = torch.zeros(half, dtype=torch.long)
for dim, offset in enumerate((1, 2), start=1):
axis[offset : mrope_section[dim] * 3 : 3] = dim
self.register_buffer("axis", axis, persistent=False)
def forward(self, position_ids: torch.Tensor, dtype: torch.dtype):
"""position_ids [3, B, T] -> cos, sin [B, T, rotary_dim]."""
pos = position_ids.float()
freqs = pos[..., None] * self.inv_freq.float()[None, None, None, :] # [3,B,T,half]
freqs = torch.gather(freqs, 0, self.axis.view(1, 1, 1, -1).expand(1, *freqs.shape[1:]))[0]
emb = torch.cat([freqs, freqs], dim=-1)
return emb.cos().to(dtype), emb.sin().to(dtype)
def table(self, max_len: int, dtype: torch.dtype):
"""1-D cos/sin table [max_len, rotary_dim] for text-only positions."""
pos = torch.arange(max_len, device=self.inv_freq.device).view(1, 1, -1).expand(3, 1, -1)
cos, sin = self.forward(pos, dtype)
return cos[0], sin[0]
# ------------------------------------------------------------------ caches
@dataclass
class DecodeCtx:
"""Per-forward decode context: where the M new tokens live in the caches."""
pos_idx: torch.Tensor # [M] int64 device: cache slots of the new tokens
qpos: torch.Tensor # [M] int64 device: absolute position of each query (for the causal mask)
cos: torch.Tensor # [B, M, R]
sin: torch.Tensor
commit: bool = True # linear layers: write the new recurrent/conv state now
min_key: int = 0 # keys below this slot are never attended (MTP slot 0 is empty)
class LayerCache:
"""Static per-layer state. Attention layers: k/v [B, Hk, L, D]. Linear layers:
conv [B, C, ks-1] (last conv inputs) and rec [B, Hv, Dk, Dv] fp32."""
def __init__(self):
self.k = self.v = None
self.conv = self.rec = None
# speculative verify stash (uncommitted step inputs)
self.spec = None
class Qwen38Cache:
def __init__(self, cfg, batch: int, max_len: int, device, dtype, layer_types=None, with_linear=True):
self.max_len = max_len
self.batch = batch
layer_types = cfg.layer_types if layer_types is None else layer_types
self.layers: List[LayerCache] = []
for lt in layer_types:
lc = LayerCache()
if lt == "full_attention":
lc.k = torch.zeros(batch, cfg.num_key_value_heads, max_len, cfg.head_dim, device=device, dtype=dtype)
lc.v = torch.zeros_like(lc.k)
elif with_linear:
conv_dim = cfg.linear_num_key_heads * cfg.linear_key_head_dim * 2 + cfg.linear_num_value_heads * cfg.linear_value_head_dim
lc.conv = torch.zeros(batch, conv_dim, cfg.linear_conv_kernel_dim - 1, device=device, dtype=dtype)
lc.rec = torch.zeros(
batch, cfg.linear_num_value_heads, cfg.linear_key_head_dim, cfg.linear_value_head_dim,
device=device, dtype=torch.float32,
)
self.layers.append(lc)
# committed length, device-side so the decode graph never syncs
self.pos = torch.zeros(1, dtype=torch.long, device=device)
def reset(self):
for lc in self.layers:
for t in (lc.k, lc.v, lc.conv, lc.rec):
if t is not None:
t.zero_()
self.pos.zero_()
# ------------------------------------------------------------------ mixers
class GatedDeltaNet(nn.Module):
def __init__(self, cfg, layer_idx: int):
super().__init__()
self.layer_idx = layer_idx
self.hidden_size = cfg.hidden_size
self.num_v_heads = cfg.linear_num_value_heads
self.num_k_heads = cfg.linear_num_key_heads
self.head_k_dim = cfg.linear_key_head_dim
self.head_v_dim = cfg.linear_value_head_dim
self.key_dim = self.head_k_dim * self.num_k_heads
self.value_dim = self.head_v_dim * self.num_v_heads
self.conv_kernel_size = cfg.linear_conv_kernel_dim
self.conv_dim = self.key_dim * 2 + self.value_dim
self.conv1d = nn.Conv1d(
self.conv_dim, self.conv_dim, bias=False, kernel_size=self.conv_kernel_size,
groups=self.conv_dim, padding=self.conv_kernel_size - 1,
)
self.dt_bias = nn.Parameter(torch.ones(self.num_v_heads))
self.A_log = nn.Parameter(torch.zeros(self.num_v_heads))
self.norm = FusedRMSNormGated(self.head_v_dim, eps=cfg.rms_norm_eps, activation="silu")
self.out_proj = nn.Linear(self.value_dim, self.hidden_size, bias=False)
self.in_proj_qkv = nn.Linear(self.hidden_size, self.conv_dim, bias=False)
self.in_proj_z = nn.Linear(self.hidden_size, self.value_dim, bias=False)
self.in_proj_b = nn.Linear(self.hidden_size, self.num_v_heads, bias=False)
self.in_proj_a = nn.Linear(self.hidden_size, self.num_v_heads, bias=False)
def _heads(self, mixed: torch.Tensor):
b, t, _ = mixed.shape
q, k, v = torch.split(mixed, [self.key_dim, self.key_dim, self.value_dim], dim=-1)
q = q.reshape(b, t, self.num_k_heads, self.head_k_dim)
k = k.reshape(b, t, self.num_k_heads, self.head_k_dim)
v = v.reshape(b, t, self.num_v_heads, self.head_v_dim)
# fla's kernels take fewer key heads than value heads natively (same result as
# the repeat_interleave transformers does, measured bit-identical)
return q, k, v
def _gates(self, a: torch.Tensor, b: torch.Tensor):
beta = b.sigmoid()
g = -self.A_log.float().exp() * F.softplus(a.float() + self.dt_bias.float())
return beta, g
def _raw_ab(self, x: torch.Tensor):
"""Pre-activation gate inputs (a, b) [B, T, Hv]: one GEMM over the stacked weights
when the two tiny projections are not wrapped (e.g. by an active LoRA)."""
pa, pb = self.in_proj_a, self.in_proj_b
if not (module_hooks_only(pa) and module_hooks_only(pb)):
return pa(x), pb(x)
w = getattr(self, "_ab_weight", None)
if w is None or w.device != x.device or w.dtype != x.dtype:
w = torch.cat([pa.weight, pb.weight], dim=0).to(x.dtype)
self._ab_weight = w
ab = F.linear(x, w)
a, b = ab[..., : self.num_v_heads], ab[..., self.num_v_heads :]
if pa._forward_hooks:
a = apply_forward_hooks(pa, x, a)
if pb._forward_hooks:
b = apply_forward_hooks(pb, x, b)
return a, b
def _finish(self, core: torch.Tensor, z: torch.Tensor):
b, t = core.shape[:2]
out = self.norm(core.reshape(-1, self.head_v_dim), z.reshape(-1, self.head_v_dim))
return self.out_proj(out.reshape(b, t, -1))
def forward(self, x: torch.Tensor, cache: Optional[LayerCache] = None, ctx: Optional[DecodeCtx] = None):
if ctx is not None:
return self.forward_decode(x, cache, ctx)
return self.forward_prefill(x, cache)
def forward_prefill(self, x: torch.Tensor, cache: Optional[LayerCache]):
b, t, _ = x.shape
mixed, z = w4a8_grouped_linear((self.in_proj_qkv, self.in_proj_z), x)
beta, g = self._gates(self.in_proj_a(x), self.in_proj_b(x))
xt = mixed.transpose(1, 2) # [B, C, T]
if cache is not None:
ks = self.conv_kernel_size - 1
cache.conv.copy_(F.pad(xt, (max(ks - t, 0), 0))[:, :, -ks:])
conv = F.silu(F.conv1d(xt, self.conv1d.weight, None, padding=self.conv_kernel_size - 1, groups=self.conv_dim)[:, :, :t])
q, k, v = self._heads(conv.transpose(1, 2))
core, final = chunk_gated_delta_rule(
q, k, v, g=g, beta=beta, initial_state=None, output_final_state=cache is not None,
use_qk_l2norm_in_kernel=True,
)
if cache is not None:
cache.rec.copy_(final)
return self._finish(core, z)
def forward_decode(self, x: torch.Tensor, cache: LayerCache, ctx: DecodeCtx):
b, m, _ = x.shape
mixed, z = w4a8_grouped_linear((self.in_proj_qkv, self.in_proj_z), x)
a_raw, b_raw = self._raw_ab(x)
# one kernel: causal depthwise conv over [state | new tokens] + silu, token major
conv = _conv_step_op(cache.conv, mixed, self.conv1d.weight, ctx.commit) # [B, M, C]
q, k, v = self._heads(conv)
# the decay / beta activations run inside the recurrent kernel
core, final = fused_recurrent_gated_delta_rule(
q, k, v, g=a_raw.float(), beta=b_raw, initial_state=cache.rec, output_final_state=True,
use_qk_l2norm_in_kernel=True, use_gate_in_kernel=True, A_log=self.A_log.float(),
dt_bias=self.dt_bias.float(), use_beta_sigmoid_in_kernel=True,
)
if ctx.commit:
cache.rec.copy_(final)
else:
cache.spec = (q, k, v, a_raw, b_raw, mixed)
return self._finish(core, z)
def commit_speculative(self, cache: LayerCache, keep: torch.Tensor, accepted: torch.Tensor):
"""After verification: advance the state through the first accepted+1 of the
stashed tokens. Rejected tokens get g=0, beta=0 (no decay, no update), so one
more recurrent pass from the committed state lands exactly on S_{accepted+1}."""
q, k, v, a_raw, b_raw, mixed = cache.spec
full = torch.cat([cache.conv, mixed.transpose(1, 2)], dim=-1) # [B, C, ks-1+M]
beta, g = self._gates(a_raw, b_raw)
keep_h = keep.view(1, -1, 1).to(g.dtype)
_, final = fused_recurrent_gated_delta_rule(
q, k, v, g=g * keep_h, beta=beta * keep_h.to(beta.dtype), initial_state=cache.rec,
output_final_state=True, use_qk_l2norm_in_kernel=True,
)
cache.rec.copy_(final)
ks = self.conv_kernel_size - 1
idx = accepted.view(1) + 1 + torch.arange(ks, device=full.device)
cache.conv.copy_(full.index_select(-1, idx))
cache.spec = None
def decode_attention(q, k_cache, v_cache, qpos, scale, min_key: int = 0):
"""Single-step GQA attention of M queries against the whole static cache.
q [B,Hq,M,D]; caches [B,Hk,L,D]; qpos [M] absolute positions. Scores in fp32
straight from the bf16 GEMM (sink logits overflow a bf16 score tensor)."""
if decode_attention_fused_ok(q, k_cache):
return _decode_attn_op(q, k_cache, v_cache, qpos, scale, min_key)
b, hq, m, d = q.shape
hk, L = k_cache.shape[1], k_cache.shape[2]
g = hq // hk
qg = q.reshape(b, hk, g, m, d).reshape(b * hk, g * m, d)
kT = k_cache.reshape(b * hk, L, d).transpose(-1, -2)
scores = torch.bmm(qg, kT, out_dtype=torch.float32).view(b * hk, g, m, L) * scale
keys = torch.arange(L, device=q.device)
allowed = keys[None, :] <= qpos[:, None] # [M, L]
if min_key > 0:
allowed = allowed & (keys[None, :] >= min_key)
scores = scores.masked_fill(~allowed[None, None], float("-inf"))
probs = torch.softmax(scores, dim=-1).to(q.dtype).view(b * hk, g * m, L)
out = torch.bmm(probs, v_cache.reshape(b * hk, L, d)) # [B*hk, g*m, d]
return out.view(b, hk, g, m, d).reshape(b, hq, m, d)
class GatedAttention(nn.Module):
def __init__(self, cfg, layer_idx: int):
super().__init__()
self.layer_idx = layer_idx
self.head_dim = cfg.head_dim
self.num_heads = cfg.num_attention_heads
self.num_kv_heads = cfg.num_key_value_heads
self.scaling = self.head_dim**-0.5
self.q_proj = nn.Linear(cfg.hidden_size, self.num_heads * self.head_dim * 2, bias=cfg.attention_bias)
self.k_proj = nn.Linear(cfg.hidden_size, self.num_kv_heads * self.head_dim, bias=cfg.attention_bias)
self.v_proj = nn.Linear(cfg.hidden_size, self.num_kv_heads * self.head_dim, bias=cfg.attention_bias)
self.o_proj = nn.Linear(self.num_heads * self.head_dim, cfg.hidden_size, bias=cfg.attention_bias)
self.q_norm = RMSNorm(self.head_dim, eps=cfg.rms_norm_eps)
self.k_norm = RMSNorm(self.head_dim, eps=cfg.rms_norm_eps)
def _qkv(self, x, cos, sin):
b, t, _ = x.shape
qg, k, v = w4a8_grouped_linear((self.q_proj, self.k_proj, self.v_proj), x)
qg = qg.view(b, t, self.num_heads, 2 * self.head_dim)
q, gate = qg[..., : self.head_dim], qg[..., self.head_dim :]
gate = gate.reshape(b, t, -1)
q = self.q_norm(q).transpose(1, 2)
k = self.k_norm(k.view(b, t, self.num_kv_heads, self.head_dim)).transpose(1, 2)
v = v.view(b, t, self.num_kv_heads, self.head_dim).transpose(1, 2)
q, k = apply_partial_rope(q, k, cos, sin)
return q, k, v, gate
def forward(self, x, cos, sin, cache: Optional[LayerCache] = None, ctx: Optional[DecodeCtx] = None, start: int = 0):
b, t, _ = x.shape
q, k, v, gate = self._qkv(x, cos, sin)
if ctx is None:
if cache is not None:
cache.k[:, :, start : start + t].copy_(k)
cache.v[:, :, start : start + t].copy_(v)
o = F.scaled_dot_product_attention(q, k, v, is_causal=True, enable_gqa=True, scale=self.scaling)
else:
cache.k.index_copy_(2, ctx.pos_idx, k)
cache.v.index_copy_(2, ctx.pos_idx, v)
o = decode_attention(q, cache.k, cache.v, ctx.qpos, self.scaling, ctx.min_key)
o = o.transpose(1, 2).reshape(b, t, -1) * torch.sigmoid(gate)
return self.o_proj(o)
class MLP(nn.Module):
def __init__(self, cfg):
super().__init__()
self.gate_proj = nn.Linear(cfg.hidden_size, cfg.intermediate_size, bias=False)
self.up_proj = nn.Linear(cfg.hidden_size, cfg.intermediate_size, bias=False)
self.down_proj = nn.Linear(cfg.intermediate_size, cfg.hidden_size, bias=False)
def forward(self, x):
gate, up = w4a8_grouped_linear((self.gate_proj, self.up_proj), x)
return self.down_proj(swiglu(gate, up))
class DecoderLayer(nn.Module):
def __init__(self, cfg, layer_idx: int, layer_type: Optional[str] = None):
super().__init__()
self.layer_type = layer_type or cfg.layer_types[layer_idx]
if self.layer_type == "linear_attention":
self.linear_attn = GatedDeltaNet(cfg, layer_idx)
else:
self.self_attn = GatedAttention(cfg, layer_idx)
self.mlp = MLP(cfg)
self.input_layernorm = RMSNorm(cfg.hidden_size, eps=cfg.rms_norm_eps)
self.post_attention_layernorm = RMSNorm(cfg.hidden_size, eps=cfg.rms_norm_eps)
def forward(self, h, cos, sin, cache=None, ctx=None, start: int = 0, pending=None):
"""pending: the previous layer's MLP output, not yet added to the residual stream;
its add is fused with this layer's input norm. Returns (h, mlp_out) the same way."""
if pending is not None:
h, x = add_rmsnorm(h, pending, self.input_layernorm.weight, self.input_layernorm.eps)
else:
x = self.input_layernorm(h)
if self.layer_type == "linear_attention":
x = self.linear_attn(x, cache, ctx)
else:
x = self.self_attn(x, cos, sin, cache, ctx, start)
h, x = add_rmsnorm(h, x, self.post_attention_layernorm.weight, self.post_attention_layernorm.eps)
return h, self.mlp(x)
# ------------------------------------------------------------------ text model / mtp / top
class Qwen38TextModel(nn.Module):
def __init__(self, cfg):
super().__init__()
self.config = cfg
self.embed_tokens = nn.Embedding(cfg.vocab_size, cfg.hidden_size, cfg.pad_token_id)
self.layers = nn.ModuleList([DecoderLayer(cfg, i) for i in range(cfg.num_hidden_layers)])
self.norm = RMSNorm(cfg.hidden_size, eps=cfg.rms_norm_eps)
rp = cfg.rope_parameters
self.rotary = InterleavedMRope(
int(cfg.head_dim * rp.get("partial_rotary_factor", 1.0)), rp["rope_theta"], rp.get("mrope_section", [11, 11, 10])
)
self.gradient_checkpointing = False
def forward(self, inputs_embeds, cos, sin, cache: Optional[Qwen38Cache] = None, ctx: Optional[DecodeCtx] = None, start: int = 0):
h = inputs_embeds
pending = None
for i, layer in enumerate(self.layers):
lc = cache.layers[i] if cache is not None else None
if self.gradient_checkpointing and torch.is_grad_enabled():
h, pending = torch.utils.checkpoint.checkpoint(layer, h, cos, sin, lc, ctx, start, pending, use_reentrant=False)
else:
h, pending = layer(h, cos, sin, lc, ctx, start, pending)
_, y = add_rmsnorm(h, pending, self.norm.weight, self.norm.eps)
return y
def commit_speculative(self, cache: Qwen38Cache, keep: torch.Tensor, accepted: torch.Tensor):
for i, layer in enumerate(self.layers):
if layer.layer_type == "linear_attention":
layer.linear_attn.commit_speculative(cache.layers[i], keep, accepted)
class Qwen38MTP(nn.Module):
"""Multi-token-prediction draft head: fc(cat[norm(embed(token_p)), norm(hidden_{p-1})])
-> one full-attention decoder layer with its own KV cache -> norm -> lm_head."""
def __init__(self, cfg):
super().__init__()
self.fc = nn.Linear(cfg.hidden_size * 2, cfg.hidden_size, bias=False)
self.pre_fc_norm_embedding = RMSNorm(cfg.hidden_size, eps=cfg.rms_norm_eps)
self.pre_fc_norm_hidden = RMSNorm(cfg.hidden_size, eps=cfg.rms_norm_eps)
self.layers = nn.ModuleList([DecoderLayer(cfg, 0, layer_type="full_attention")])
self.norm = RMSNorm(cfg.hidden_size, eps=cfg.rms_norm_eps)
def forward(self, embeds, hidden, cos, sin, cache: LayerCache, ctx: Optional[DecodeCtx] = None, start: int = 0):
x = self.fc(torch.cat([self.pre_fc_norm_embedding(embeds), self.pre_fc_norm_hidden(hidden)], dim=-1))
h, mlp = self.layers[0](x, cos, sin, cache, ctx, start)
h, y = add_rmsnorm(h, mlp, self.norm.weight, self.norm.eps)
return y, h
class Qwen38Inner(nn.Module):
def __init__(self, config):
super().__init__()
self.visual = Qwen3_5VisionModel._from_config(config.vision_config)
self.language_model = Qwen38TextModel(config.text_config)
class Qwen38ForConditionalGeneration(nn.Module):
def __init__(self, config, with_mtp: bool = True):
super().__init__()
self.config = config
self.model = Qwen38Inner(config)
tc = config.text_config
self.lm_head = nn.Linear(tc.hidden_size, tc.vocab_size, bias=False)
self.mtp = Qwen38MTP(tc) if with_mtp else None
self.generator: Optional["Qwen38Generator"] = None
@property
def text_config(self):
return self.config.text_config
@property
def device(self):
return self.model.language_model.norm.weight.device
def get_input_embeddings(self):
return self.model.language_model.embed_tokens
# -------------------------------------------------- vision
@torch.no_grad()
def get_image_features(self, pixel_values: torch.Tensor, grid_thw: torch.Tensor) -> torch.Tensor:
"""Merged vision embeddings [N_merged, hidden] for one or more images / videos."""
visual = self.model.visual
out = visual(pixel_values.to(visual.patch_embed.proj.weight.dtype), grid_thw=grid_thw, return_dict=True)
return out.pooler_output
# -------------------------------------------------- positions
def get_rope_index(self, input_ids: torch.Tensor, mm_token_type_ids: torch.Tensor, image_grid_thw=None, video_grid_thw=None):
"""Port of transformers' Qwen3_5 get_rope_index for one unpadded sequence.
input_ids / mm_token_type_ids [T]; returns position_ids [3, 1, T] and the rope
delta (first text position after the sequence minus its length)."""
merge = self.config.vision_config.spatial_merge_size
device = input_ids.device
if video_grid_thw is not None:
video_grid_thw = torch.repeat_interleave(video_grid_thw, video_grid_thw[:, 0], dim=0)
video_grid_thw[:, 0] = 1
grids = {1: iter(image_grid_thw) if image_grid_thw is not None else None, 2: iter(video_grid_thw) if video_grid_thw is not None else None}
types = mm_token_type_ids.tolist()
groups = []
i = 0
while i < len(types):
j = i
while j < len(types) and types[j] == types[i]:
j += 1
groups.append((types[i], i, j))
i = j
cur = 0
parts = []
for kind, s, e in groups:
if kind == 0:
n = e - s
parts.append(torch.arange(n, device=device).view(1, -1).expand(3, -1) + cur)
cur += n
else:
thw = next(grids[kind])
t, h, w = int(thw[0]), int(thw[1]) // merge, int(thw[2]) // merge
n = t * h * w
pw = torch.arange(cur, cur + w, device=device).repeat(h * t)
ph = torch.arange(cur, cur + h, device=device).repeat_interleave(w * t)
pt = torch.full((n,), cur, device=device, dtype=torch.long)
parts.append(torch.stack([pt, ph, pw], dim=0))
cur += max(h, w)
pos = torch.cat(parts, dim=1)
delta = int(pos.max().item()) + 1 - input_ids.shape[0]
return pos.view(3, 1, -1), delta
# -------------------------------------------------- forward (prefill / training)
def forward_hidden(self, inputs_embeds, position_ids, cache: Optional[Qwen38Cache] = None):
"""Post-norm hidden states [B, T, H] for a full sequence (prefill or training)."""
lm = self.model.language_model
cos, sin = lm.rotary(position_ids, inputs_embeds.dtype)
return lm(inputs_embeds, cos, sin, cache=cache)
def enable_gradient_checkpointing(self):
self.model.language_model.gradient_checkpointing = True
# ------------------------------------------------------------------ generation
class Qwen38Generator:
"""Greedy generation with static caches and CUDA-graph decode steps; optional MTP
speculative decoding (draft depth D). One instance per model; caches and graphs
are reused across prompts and rebuilt when the length budget grows."""
def __init__(self, model: Qwen38ForConditionalGeneration, use_cuda_graph: bool = True):
self.model = model
self.cfg = model.text_config
self.use_cuda_graph = use_cuda_graph
self.cache: Optional[Qwen38Cache] = None
self.mtp_cache: Optional[Qwen38Cache] = None
self.max_len = 0
self.graphs: Dict[tuple, Tuple[torch.cuda.CUDAGraph, dict]] = {}
self.dtype = model.model.language_model.norm.weight.dtype
self.device = model.device
self.rope_table = None
self.last_stats = {}
# ---------------- buffers
def _ensure(self, max_len: int, batch: int = 1):
if self.cache is not None and self.max_len >= max_len and self.cache.batch == batch:
return
self.graphs = {}
self.max_len = max_len
self.cache = Qwen38Cache(self.cfg, batch, max_len, self.device, self.dtype)
if self.model.mtp is not None:
self.mtp_cache = Qwen38Cache(self.cfg, batch, max_len, self.device, self.dtype, layer_types=["full_attention"], with_linear=False)
cos, sin = self.model.model.language_model.rotary.table(max_len + 64, self.dtype)
self.rope_table = (cos, sin)
self.rope_delta = torch.zeros(1, dtype=torch.long, device=self.device)
hs = self.cfg.hidden_size
self.nt_buf = torch.zeros(batch, 1, dtype=torch.long, device=self.device)
self.h_buf = torch.zeros(batch, 1, hs, dtype=self.dtype, device=self.device)
self.out_buf = torch.zeros(1 + 16, dtype=torch.long, device=self.device)
def _cos_sin(self, positions: torch.Tensor):
cos, sin = self.rope_table
idx = (positions + self.rope_delta).clamp_(min=0)
return cos.index_select(0, idx)[None], sin.index_select(0, idx)[None]
def _embed(self, ids: torch.Tensor):
return self.model.get_input_embeddings()(ids).to(self.dtype)
def _lm_head_argmax(self, hidden: torch.Tensor) -> torch.Tensor:
return self.model.lm_head(hidden).argmax(dim=-1)
# ---------------- step bodies (graph-capturable: no host syncs)
def _plain_step(self):
cache = self.cache
pos = cache.pos
ctx = DecodeCtx(pos_idx=pos, qpos=pos, cos=None, sin=None, commit=True)
ctx.cos, ctx.sin = self._cos_sin(pos)
hidden = self.model.model.language_model(self._embed(self.nt_buf), ctx.cos, ctx.sin, cache=cache, ctx=ctx)
nt = self._lm_head_argmax(hidden[:, -1:])
self.out_buf[0].fill_(0)
self.out_buf[1].copy_(nt.view(-1)[0])
self.nt_buf.copy_(nt)
self.h_buf.copy_(hidden[:, -1:])
pos.add_(1)
def _mtp_step(self, depth: int):
model, cache, mtp_cache = self.model, self.cache, self.mtp_cache
lm = model.model.language_model
pos = cache.pos
ar = torch.arange(depth + 1, device=self.device)
tok_in, hid_in = self.nt_buf, self.h_buf
drafts = []
for k in range(depth):
p = pos + k
ctx = DecodeCtx(pos_idx=p, qpos=p, cos=None, sin=None, min_key=1)
ctx.cos, ctx.sin = self._cos_sin(p)
normed, pre = model.mtp(self._embed(tok_in), hid_in, ctx.cos, ctx.sin, mtp_cache.layers[0], ctx)
tok_in = self._lm_head_argmax(normed)
hid_in = pre
drafts.append(tok_in)
drafts = torch.cat(drafts, dim=1) # [B, D]
tokens = torch.cat([self.nt_buf, drafts], dim=1) # [B, D+1]
positions = pos + ar
ctx = DecodeCtx(pos_idx=positions, qpos=positions, cos=None, sin=None, commit=False)
ctx.cos, ctx.sin = self._cos_sin(positions)
hidden = lm(self._embed(tokens), ctx.cos, ctx.sin, cache=cache, ctx=ctx) # [B, D+1, H]
pred = self._lm_head_argmax(hidden) # [B, D+1]
match = (pred[:, :depth] == drafts).long()
accepted = match.cumprod(dim=1).sum(dim=1)[0] # scalar in [0, D]
keep = (ar <= accepted).long()
lm.commit_speculative(cache, keep, accepted)
# refresh the draft cache from the verified hidden states: pair (token_p, hidden_{p-1})
prev_hidden = torch.cat([self.h_buf, hidden[:, :depth]], dim=1)
rctx = DecodeCtx(pos_idx=positions, qpos=positions, cos=ctx.cos, sin=ctx.sin, min_key=1)
model.mtp(self._embed(tokens), prev_hidden, rctx.cos, rctx.sin, mtp_cache.layers[0], rctx)
# committed tokens: drafts[:a] then pred[a]; report [a, tok_0..tok_D]
drafts_ext = torch.cat([drafts, pred[:, -1:]], dim=1)[0]
committed = torch.where(ar < accepted, drafts_ext, pred[0])
self.out_buf[0].copy_(accepted)
self.out_buf[1 : depth + 2].copy_(committed)
a_idx = accepted.view(1, 1)
self.nt_buf.copy_(pred.gather(1, a_idx))
self.h_buf.copy_(hidden.gather(1, a_idx.view(1, 1, 1).expand(1, 1, hidden.shape[-1])))
pos.add_(accepted + 1)
def _adapter_signature(self):
"""Captured graphs bake in the adapter wrappers' Python-side decisions (active /
merged / multiplier), so the graph cache is keyed on them."""
hooks = 0
for mod in self.model.model.language_model.modules():
if not isinstance(mod, nn.Linear):
continue
hooks += len(mod._forward_hooks) + len(mod._forward_pre_hooks)
fwd = mod.__dict__.get("forward")
if fwd is None:
continue
lora = getattr(fwd, "__self__", None)
net_ref = getattr(lora, "network_ref", None)
net = net_ref() if callable(net_ref) else None
if net is None:
return ("wrapped", id(lora))
return (id(net), bool(getattr(net, "is_active", True)), bool(getattr(net, "is_merged_in", False)), repr(getattr(net, "_multiplier", None)))
# inference LoRA sidechains are forward hooks; their count keys the graph too
return ("hooks", hooks) if hooks else None
def _get_step(self, depth: int):
key = (("mtp", depth) if depth > 0 else ("plain",)) + (self._adapter_signature(),)
fn = (lambda: self._mtp_step(depth)) if depth > 0 else self._plain_step
if not self.use_cuda_graph:
return fn
if key not in self.graphs:
# warm up (triton autotune / allocator) on a side stream, then capture
s = torch.cuda.Stream()
s.wait_stream(torch.cuda.current_stream())
saved = self._snapshot_state()
with torch.cuda.stream(s):
for _ in range(2):
fn()
self._restore_state(saved)
torch.cuda.current_stream().wait_stream(s)
# two instantiations of the same step: a graph cannot be relaunched until its
# previous replay has finished, so alternating two hides the (ms-scale) CPU
# launch cost of a ~2000-node graph behind the other one's execution
graphs = []
for _ in range(2):
g = torch.cuda.CUDAGraph()
with torch.cuda.graph(g):
fn()
self._restore_state(saved)
graphs.append(g)
self.graphs[key] = graphs
graphs = self.graphs[key]
state = {"i": 0}
def replay():
graphs[state["i"]].replay()
state["i"] ^= 1
return replay
def _snapshot_state(self):
c = self.cache
snap = {"pos": c.pos.clone(), "nt": self.nt_buf.clone(), "h": self.h_buf.clone(), "layers": []}
for lc in c.layers:
snap["layers"].append(tuple(t.clone() if t is not None else None for t in (lc.conv, lc.rec)))
snap["mtp_pos"] = self.mtp_cache.pos.clone() if self.mtp_cache is not None else None
return snap
def _restore_state(self, snap):
c = self.cache
c.pos.copy_(snap["pos"])
self.nt_buf.copy_(snap["nt"])
self.h_buf.copy_(snap["h"])
for lc, (conv, rec) in zip(c.layers, snap["layers"]):
if conv is not None:
lc.conv.copy_(conv)
lc.rec.copy_(rec)
if self.mtp_cache is not None and snap["mtp_pos"] is not None:
self.mtp_cache.pos.copy_(snap["mtp_pos"])
# ---------------- prefill + loop
@torch.no_grad()
def generate(
self,
inputs_embeds: torch.Tensor,
input_ids: torch.Tensor,
position_ids: torch.Tensor,
rope_delta: int,
max_new_tokens: int,
eos_ids,
mtp_depth: int = 0,
progress=None,
) -> List[int]:
"""inputs_embeds [1, T, H] (media already scattered in), input_ids [1, T],
position_ids [3, 1, T]. Returns the generated token ids (eos included)."""
model = self.model
b, t, _ = inputs_embeds.shape
if b != 1:
raise ValueError("Qwen38Generator decodes one sequence at a time")
if mtp_depth > 0 and model.mtp is None:
mtp_depth = 0
need = t + max_new_tokens + mtp_depth + 2
if self.cache is None or self.max_len < need:
# round up so a slightly longer prompt does not rebuild caches and graphs
self._ensure(max(need, 2048) if self.cache is None else -(-need // 1024) * 1024)
cache, mtp_cache = self.cache, self.mtp_cache
cache.reset()
if mtp_cache is not None:
mtp_cache.reset()
self.rope_delta.fill_(rope_delta)
eos = set(int(e) for e in eos_ids)
# prefill (eager): all layers over the prompt, caches written at slots 0..T-1
lm = model.model.language_model
cos, sin = lm.rotary(position_ids, inputs_embeds.dtype)
hidden = lm(inputs_embeds, cos, sin, cache=cache) # [1, T, H]
nt = self._lm_head_argmax(hidden[:, -1:]) # [1, 1]
cache.pos.fill_(t)
if mtp_depth > 0:
# draft cache over the prompt: slot p = (token_p, hidden_{p-1}), p = 1..T-1
model.mtp(self._embed(input_ids[:, 1:]), hidden[:, :-1], cos[:, 1:], sin[:, 1:], mtp_cache.layers[0], None, start=1)
self.nt_buf.copy_(nt)
self.h_buf.copy_(hidden[:, -1:])
out = [int(nt.item())]
if out[-1] in eos:
return out
step = self._get_step(mtp_depth)
n_steps = 0
n_accepted = 0
# pipelined: step k+1 is queued before step k's result is read, so the host-side
# bookkeeping overlaps the GPU instead of leaving it idle at every sync. The one
# extra step launched after the stop condition is harmless (its state is discarded).
host = [torch.empty_like(self.out_buf, device="cpu", pin_memory=True) for _ in range(2)]
events = [torch.cuda.Event(), torch.cuda.Event()]
slot = 0
step()
host[slot].copy_(self.out_buf, non_blocking=True)
events[slot].record()
done = False
while not done:
nxt = slot ^ 1
step()
host[nxt].copy_(self.out_buf, non_blocking=True)
events[nxt].record()
events[slot].synchronize()
res = host[slot].tolist()
n_steps += 1
if mtp_depth > 0:
a = res[0]
n_accepted += a
new = res[1 : a + 2]
else:
new = res[1:2]
for tok in new:
out.append(tok)
if tok in eos or len(out) >= max_new_tokens:
done = True
break
if progress is not None:
progress(len(out))
slot = nxt
torch.cuda.current_stream().synchronize()
self.last_stats = {"steps": n_steps, "tokens": len(out), "accepted_drafts": n_accepted}
return out
def build_sequence_embeds(model: Qwen38ForConditionalGeneration, input_ids: torch.Tensor, media_embeds: Optional[torch.Tensor], placeholder_ids):
"""Token embeddings with the media placeholder positions replaced by the vision
embeddings. input_ids [T]; media_embeds [N, H] (rows in placeholder order)."""
emb = model.get_input_embeddings()(input_ids[None])
if media_embeds is not None:
mask = torch.zeros_like(input_ids, dtype=torch.bool)
for pid in placeholder_ids:
mask |= input_ids == pid
n = int(mask.sum())
if n != media_embeds.shape[0]:
raise ValueError(f"media placeholder count {n} != media embeddings {media_embeds.shape[0]}")
emb = emb.masked_scatter(mask[None, :, None].expand_as(emb), media_embeds.to(emb.dtype))
return emb
+73
View File
@@ -81,4 +81,77 @@ export const AI_TOOLKIT_UI_MODELS: ModelArch[] = [
</div>
),
},
{
name: "qwen38",
label: "Qwen3.8 27B",
group: "llm",
defaults: {
// default updates when [selected, unselected] in the UI
"config.process[0].model.name_or_path": [
"Comfy-Org/Qwen3.8-27B/text_encoders/qwen3.8_27b_w4a8.safetensors",
defaultNameOrPath,
],
"config.process[0].model.quantize": [true, false],
"config.process[0].model.quantize_te": [false, false],
"config.process[0].model.low_vram": [false, false],
// the ComfyUI file ships W4A8 (int4 codebook weights, int8 activations); requesting it keeps the layers as-is
"config.process[0].model.qtype": ["convrotw4a8", "qfloat8"],
"config.process[0].train.unload_text_encoder": [false, false],
"config.process[0].train.noise_scheduler": ["flowmatch", "flowmatch"],
"config.process[0].train.batch_size": [1, 1],
"config.process[0].sample": [
defaultQwen25OmniSampleConfig,
defaultSampleConfig,
],
// media is encoded on the GPU per step; the cache is optional
"config.process[0].datasets[x].cache_latents_to_disk": [false, true],
"config.process[0].datasets[x].resolution": [[1024], [512, 768, 1024]],
// the caption is the training target; a blank one trains nothing
"config.process[0].datasets[x].caption_dropout_rate": [0, 0.05],
"config.process[0].model.model_kwargs": [
{ instruction: "Describe this in detail." },
{},
],
},
disableSections: [
"network.conv",
"trigger_word",
"train.diff_output_preservation",
"train.blank_prompt_preservation",
"train.unload_text_encoder",
"slider",
],
additionalSections: [
"model.model_kwargs.instruction",
"sample.ctrl_img",
"datasets.num_frames",
],
modelNotes: (
<div className="space-y-2">
<p>
Qwen3.8 27B as a text-generating vision-language model: image or video
in, text out. Each dataset item is a media file (jpg, png, webp, mp4,
...) with a caption file next to it; the caption is the text the model
learns to produce for that media. Video files are used when Num Frames
is above 1 and are seen as frames only (no audio).
</p>
<p>
The default weights are the ComfyUI W4A8 file (4-bit codebook weights,
8-bit activations, about 17 GB) and are run in that format: a fused
int4 decode kernel for generation, int8 tensor-core GEMMs for training.
Generation uses the model&apos;s MTP draft head for speculative decoding
(<code>model_kwargs.mtp_depth</code>, default 3) and CUDA-graph decode
steps.
</p>
<p>
The instruction in <code>model_kwargs.instruction</code> is the user
turn for every training item; use the same wording when captioning
with the trained LoRA. Thinking mode is off by default
(<code>model_kwargs.enable_thinking</code>). Batch size is 1; the LoRA
trains the text stack only. Watch <code>loss/ce</code>: next-token
cross-entropy on the caption.
</p>
</div>
),
},
];