streamembed 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- streamembed/__init__.py +7 -0
- streamembed/api.py +205 -0
- streamembed/capture.py +88 -0
- streamembed/encoder.py +108 -0
- streamembed/engine/__init__.py +1 -0
- streamembed/engine/config.py +82 -0
- streamembed/engine/engine.py +90 -0
- streamembed/engine/llm.py +170 -0
- streamembed/engine/macs.py +46 -0
- streamembed/engine/model.py +254 -0
- streamembed/engine/qwen_embedder.py +106 -0
- streamembed/engine/spatial.py +126 -0
- streamembed/engine/temporal.py +108 -0
- streamembed/engine/vit.py +86 -0
- streamembed/gate.py +102 -0
- streamembed/index.py +138 -0
- streamembed/models.py +90 -0
- streamembed/static/results.html +148 -0
- streamembed/visualize.py +39 -0
- streamembed-0.1.0.dist-info/METADATA +122 -0
- streamembed-0.1.0.dist-info/RECORD +24 -0
- streamembed-0.1.0.dist-info/WHEEL +4 -0
- streamembed-0.1.0.dist-info/licenses/LICENSE +201 -0
- streamembed-0.1.0.dist-info/licenses/NOTICE +11 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""LLM part: token sequence, exact prefix reuse (with KV Reuse (ViT)) and KV Reuse (LLM).
|
|
2
|
+
|
|
3
|
+
Sequence = [prompt head (first image token at `first`)] + [one visual token per kept block] + [text tail incl. the final token].
|
|
4
|
+
The embedding is the normalised final hidden state of the last token.
|
|
5
|
+
|
|
6
|
+
Exact prefix reuse (on with KV Reuse): the LLM is causal, so positions before the first changed position P are bit-identical to the
|
|
7
|
+
previous frame. Crop the previous frame's KV cache to P and prefill only [P, L). If nothing changed, return the previous embedding.
|
|
8
|
+
|
|
9
|
+
KV Reuse (LLM) (approximate): recompute only R = visual tokens of changed blocks + every text token after the first change + the final token
|
|
10
|
+
(+ any slot without cached K/V). Unchanged visual tokens after the first change reuse their cached per-layer K/V although their causal
|
|
11
|
+
context changed: this is the approximation. Text after the change is always recomputed because it summarises the image.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import torch
|
|
17
|
+
import torch.nn.functional as F
|
|
18
|
+
from transformers.cache_utils import DynamicCache
|
|
19
|
+
from transformers.models.qwen3_vl import modeling_qwen3_vl as MQ
|
|
20
|
+
|
|
21
|
+
from .model import Grid, Model
|
|
22
|
+
from .temporal import FramePlan, to_device
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def full_grid_slots(g: Grid, kept: np.ndarray) -> np.ndarray:
|
|
26
|
+
"""Full-grid slot of each position of the (pruned) sequence."""
|
|
27
|
+
return np.concatenate([np.arange(g.first), g.first + kept, np.arange(g.last + 1, g.length)])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def first_changed_position(g: Grid, plan: FramePlan) -> int:
|
|
31
|
+
"""P: length of the prefix identical to the previous frame's sequence (0 without history)."""
|
|
32
|
+
st = plan.state
|
|
33
|
+
if st is None or st.prev_kept is None:
|
|
34
|
+
return 0
|
|
35
|
+
kept, prev = plan.kept, st.prev_kept
|
|
36
|
+
recomputed = np.zeros(g.n_blocks, bool)
|
|
37
|
+
recomputed[plan.recompute] = True
|
|
38
|
+
n = min(len(kept), len(prev))
|
|
39
|
+
same = (kept[:n] == prev[:n]) & ~recomputed[kept[:n]] # same block in the same slot and not recomputed
|
|
40
|
+
j = int(np.argmin(same)) if not same.all() else n
|
|
41
|
+
if j == n and len(kept) == len(prev):
|
|
42
|
+
return g.llm_len(len(kept)) # nothing changed
|
|
43
|
+
return g.first + j
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def plan_llm(g: Grid, plan: FramePlan, approx_llm: bool):
|
|
47
|
+
"""Fill plan.prefix, plan.skip_llm and (KV Reuse (LLM)) plan.llm_rows, on the host."""
|
|
48
|
+
st = plan.state
|
|
49
|
+
L = g.llm_len(len(plan.kept))
|
|
50
|
+
plan.prefix = first_changed_position(g, plan)
|
|
51
|
+
plan.skip_llm = st is not None and plan.prefix >= L and st.prev_emb is not None
|
|
52
|
+
if approx_llm and not plan.skip_llm:
|
|
53
|
+
plan.llm_rows = recompute_rows(g, plan)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def recompute_rows(g: Grid, plan: FramePlan) -> np.ndarray:
|
|
57
|
+
"""KV Reuse (LLM): R, the sorted positions to run through the LLM (always contains the final position L-1)."""
|
|
58
|
+
f, kept, P = g.first, plan.kept, plan.prefix
|
|
59
|
+
nk = len(kept)
|
|
60
|
+
L = g.llm_len(nk)
|
|
61
|
+
r = np.zeros(L, bool)
|
|
62
|
+
if plan.refresh or P == 0:
|
|
63
|
+
r[P:] = True
|
|
64
|
+
else:
|
|
65
|
+
changed = np.zeros(g.n_blocks, bool)
|
|
66
|
+
changed[plan.recompute] = True
|
|
67
|
+
r[f:f + nk] = changed[kept] # visual tokens of recomputed blocks
|
|
68
|
+
r[max(P, f + nk):] = True # text after the first change
|
|
69
|
+
r |= ~plan.state.llm_valid[full_grid_slots(g, kept)] # slots never computed in this stream
|
|
70
|
+
r[L - 1] = True
|
|
71
|
+
return np.flatnonzero(r)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def llm_inputs(g: Grid, kept: np.ndarray, kept_t: torch.Tensor, visual_tokens: torch.Tensor, m: Model):
|
|
75
|
+
"""Input embeddings and mRoPE positions of the (pruned) sequence.
|
|
76
|
+
changed: dropped blocks are removed from the sequence; the rest keep their full-grid mRoPE positions."""
|
|
77
|
+
seq = torch.cat([torch.arange(g.first, device=m.device), g.first + kept_t, torch.arange(g.last + 1, g.length, device=m.device)])
|
|
78
|
+
x = g.token_emb[seq].clone()
|
|
79
|
+
x[g.first:g.first + len(kept)] = visual_tokens.to(x.dtype)
|
|
80
|
+
return x, g.llm_pos[:, seq]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@torch.inference_mode()
|
|
84
|
+
def run_exact(m: Model, g: Grid, plan: FramePlan, visual_tokens: torch.Tensor, deepstack: list) -> torch.Tensor:
|
|
85
|
+
"""Stock HF LLM forward; with KV Reuse (ViT) it continues from the previous frame's KV cache cropped to P (exact)."""
|
|
86
|
+
st = plan.state
|
|
87
|
+
if plan.skip_llm:
|
|
88
|
+
return st.prev_emb
|
|
89
|
+
x, pos = llm_inputs(g, plan.kept, plan.kept_t, visual_tokens, m)
|
|
90
|
+
L, f, nk = x.shape[0], g.first, len(plan.kept)
|
|
91
|
+
P = plan.prefix
|
|
92
|
+
if st is None or st.exact_kv is None or P == 0:
|
|
93
|
+
kv, P = DynamicCache(config=m.tcfg), 0
|
|
94
|
+
else:
|
|
95
|
+
kv = st.exact_kv # changed: reuse the previous frame's cache, cropped to P
|
|
96
|
+
cur = kv.get_seq_length()
|
|
97
|
+
if cur > P:
|
|
98
|
+
kv.crop(-(cur - P))
|
|
99
|
+
visual = torch.zeros(L, dtype=torch.bool, device=m.device)
|
|
100
|
+
visual[f:f + nk] = True
|
|
101
|
+
n_vis_in_prefix = min(max(P - f, 0), nk)
|
|
102
|
+
hs = m.llm(input_ids=None, inputs_embeds=x[None, P:], position_ids=pos[:, None, P:], past_key_values=kv, use_cache=True,
|
|
103
|
+
visual_pos_masks=visual[None, P:],
|
|
104
|
+
deepstack_visual_embeds=[d[n_vis_in_prefix:].to(x.dtype) for d in deepstack]).last_hidden_state
|
|
105
|
+
if st is not None:
|
|
106
|
+
st.exact_kv = kv
|
|
107
|
+
return F.normalize(hs[0, -1].float(), p=2, dim=-1)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@torch.inference_mode()
|
|
111
|
+
def run_approx(m: Model, g: Grid, plan: FramePlan, visual_tokens: torch.Tensor, deepstack: list) -> torch.Tensor:
|
|
112
|
+
"""Run only rows R through every LLM layer. Per layer, write their fresh K/V into the full-grid slot cache and attend causally
|
|
113
|
+
(by sequence index) over the cached K/V of all current positions.
|
|
114
|
+
changed: re-implements the Qwen3 decoder layer (same modules) with a row subset and a slot-indexed K/V cache."""
|
|
115
|
+
st = plan.state
|
|
116
|
+
if plan.skip_llm:
|
|
117
|
+
return st.prev_emb
|
|
118
|
+
tc = m.tcfg
|
|
119
|
+
nh, nkv, hd = tc.num_attention_heads, tc.num_key_value_heads, tc.head_dim
|
|
120
|
+
f, nk = g.first, len(plan.kept)
|
|
121
|
+
x, pos = llm_inputs(g, plan.kept, plan.kept_t, visual_tokens, m)
|
|
122
|
+
rows_np = plan.llm_rows
|
|
123
|
+
slots_np = full_grid_slots(g, plan.kept)
|
|
124
|
+
is_vis = (rows_np >= f) & (rows_np < f + nk)
|
|
125
|
+
rows, slots = to_device(rows_np, m), to_device(slots_np, m)
|
|
126
|
+
row_slots = to_device(slots_np[rows_np], m)
|
|
127
|
+
vis_rows = to_device(np.flatnonzero(is_vis), m) # rows (within R) that are visual tokens
|
|
128
|
+
ds_rows = to_device(rows_np[is_vis] - f, m) # their index among the kept blocks
|
|
129
|
+
h = x[rows]
|
|
130
|
+
rpos = pos[:, rows][:, None]
|
|
131
|
+
if rpos.shape[0] == 4: # (text, t, h, w) layout -> the 3 mRoPE rows
|
|
132
|
+
rpos = rpos[1:]
|
|
133
|
+
ds = [d[ds_rows].to(h.dtype) for d in deepstack]
|
|
134
|
+
cos, sin = m.llm.rotary_emb(h, rpos)
|
|
135
|
+
cos, sin = cos[0], sin[0]
|
|
136
|
+
mask = (torch.arange(len(slots_np), device=m.device)[None, :] <= rows[:, None])[None, None] # causal over sequence index
|
|
137
|
+
for layer_id, layer in enumerate(m.llm.layers):
|
|
138
|
+
at = layer.self_attn
|
|
139
|
+
q, k, v = KERNELS["llm_qkv"](layer, h, cos, sin, nh, nkv, hd)
|
|
140
|
+
st.llm_k[layer_id][row_slots] = k
|
|
141
|
+
st.llm_v[layer_id][row_slots] = v
|
|
142
|
+
kf, vf = st.llm_k[layer_id][slots], st.llm_v[layer_id][slots]
|
|
143
|
+
o = F.scaled_dot_product_attention(q.transpose(0, 1)[None], kf.transpose(0, 1)[None], vf.transpose(0, 1)[None],
|
|
144
|
+
attn_mask=mask, dropout_p=0.0, scale=at.scaling, enable_gqa=True)
|
|
145
|
+
h = KERNELS["llm_out"](layer, h, o, len(rows_np))
|
|
146
|
+
if layer_id < len(ds) and len(vis_rows): # DeepStack: add ViT features after early layers
|
|
147
|
+
h[vis_rows] = h[vis_rows] + ds[layer_id]
|
|
148
|
+
st.llm_valid[slots_np[rows_np]] = True
|
|
149
|
+
return F.normalize(m.llm.norm(h[-1]).float(), p=2, dim=-1) # R is sorted and ends with the final position
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _llm_qkv(layer, h, cos, sin, nh: int, nkv: int, hd: int):
|
|
153
|
+
"""input RMSNorm -> q/k/v projections -> q/k RMSNorm -> mRoPE (the dense part before attention)."""
|
|
154
|
+
at = layer.self_attn
|
|
155
|
+
a = layer.input_layernorm(h)
|
|
156
|
+
q = at.q_norm(at.q_proj(a).view(-1, nh, hd))
|
|
157
|
+
k = at.k_norm(at.k_proj(a).view(-1, nkv, hd))
|
|
158
|
+
v = at.v_proj(a).view(-1, nkv, hd)
|
|
159
|
+
q, k = MQ.apply_rotary_pos_emb(q, k, cos, sin, unsqueeze_dim=1)
|
|
160
|
+
return q, k, v
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _llm_out(layer, h, o, n: int):
|
|
164
|
+
"""o_proj + residual, then post-attention RMSNorm -> MLP + residual (the dense part after attention)."""
|
|
165
|
+
h = h + layer.self_attn.o_proj(o[0].transpose(0, 1).reshape(n, -1))
|
|
166
|
+
return h + layer.mlp(layer.post_attention_layernorm(h))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# changed: the dense per-layer parts of KV Reuse (LLM) are looked up here so cpu_compile.enable() can swap in torch.compile'd versions
|
|
170
|
+
KERNELS = {"llm_qkv": _llm_qkv, "llm_out": _llm_out}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Multiply-accumulate (MAC) count of the work actually done per frame (weights and attention; norms / activations ignored).
|
|
2
|
+
|
|
3
|
+
All results are Python ints (no overflow). MAC fraction = (vit_macs + llm_macs) / full_frame_macs(m, grid).
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from .model import Grid, Model
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def vit_macs(m: Model, n_query: int, n_kv: int, n_merge: int) -> int:
|
|
13
|
+
"""ViT with n_query query patches attending over n_kv patches in every layer; n_merge blocks through merger + DeepStack mergers."""
|
|
14
|
+
cv = m.vcfg
|
|
15
|
+
d, inter = cv.hidden_size, cv.intermediate_size
|
|
16
|
+
mh = d * cv.spatial_merge_size ** 2
|
|
17
|
+
per_layer = n_query * (4 * d * d + 2 * d * inter) + 2 * n_query * n_kv * d
|
|
18
|
+
patch_embed = n_query * cv.in_channels * cv.temporal_patch_size * cv.patch_size ** 2 * d
|
|
19
|
+
mergers = n_merge * (len(m.deepstack_layers) + 1) * (mh * mh + mh * cv.out_hidden_size)
|
|
20
|
+
return len(m.vit.blocks) * per_layer + patch_embed + mergers
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _llm_linear_per_token(m: Model) -> int:
|
|
24
|
+
tc = m.tcfg
|
|
25
|
+
H, nh, nk, hd, inter = tc.hidden_size, tc.num_attention_heads, tc.num_key_value_heads, tc.head_dim, tc.intermediate_size
|
|
26
|
+
return 2 * H * nh * hd + 2 * H * nk * hd + 3 * H * inter # q,o + k,v + gate,up,down
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def llm_macs_suffix(m: Model, P: int, L: int) -> int:
|
|
30
|
+
"""Exact path: prefill positions [P, L); position i attends over i+1 positions."""
|
|
31
|
+
tc = m.tcfg
|
|
32
|
+
att = 2 * tc.num_attention_heads * tc.head_dim * (L * (L + 1) - P * (P + 1)) // 2
|
|
33
|
+
return tc.num_hidden_layers * ((L - P) * _llm_linear_per_token(m) + att)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def llm_macs_rows(m: Model, rows: np.ndarray) -> int:
|
|
37
|
+
"""KV Reuse (LLM) path: only positions `rows` (the host indices that were executed); row i attends over i+1 positions."""
|
|
38
|
+
tc = m.tcfg
|
|
39
|
+
rows = np.asarray(rows, dtype=np.int64)
|
|
40
|
+
att = 2 * tc.num_attention_heads * tc.head_dim * int((rows + 1).sum())
|
|
41
|
+
return tc.num_hidden_layers * (len(rows) * _llm_linear_per_token(m) + att)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def full_frame_macs(m: Model, g: Grid) -> int:
|
|
45
|
+
"""FULL config on this resolution (3.084e12 for 1280x704 / 880 blocks)."""
|
|
46
|
+
return vit_macs(m, g.n_patches, g.n_patches, g.n_blocks) + llm_macs_suffix(m, 0, g.length)
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Model loading (+ LoRA, optional bf16 / bf16-llm-fp32 weight storage), the linear patch embedding and per-resolution constants.
|
|
2
|
+
|
|
3
|
+
Qwen3-VL-Embedding-2B = ViT (16x16-px patches) + LLM; the embedding is the normalised final hidden state of the last token.
|
|
4
|
+
A 1280x704 screenshot gives 80x44 patches; the ViT merger fuses each 2x2 group of patches (= one 32x32-px "block") into one LLM token,
|
|
5
|
+
so 880 blocks / visual tokens. The processor already orders patches block by block (4 consecutive rows = one block).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import warnings
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import torch
|
|
14
|
+
import torch.nn.functional as F
|
|
15
|
+
from transformers.models.qwen3_vl import modeling_qwen3_vl as MQ
|
|
16
|
+
|
|
17
|
+
from .config import MODEL_ID
|
|
18
|
+
from .qwen_embedder import Qwen3VLEmbedder
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Model:
|
|
23
|
+
embedder: Qwen3VLEmbedder # processor + HF model; also the text embedder of the OCR route
|
|
24
|
+
device: torch.device
|
|
25
|
+
dtype: torch.dtype
|
|
26
|
+
grids: dict = field(default_factory=dict) # (grid_thw, prompt length) -> Grid
|
|
27
|
+
|
|
28
|
+
def __post_init__(self):
|
|
29
|
+
base = self.embedder.model
|
|
30
|
+
self.core = base.model # HF Qwen3VLModel
|
|
31
|
+
self.vit = self.core.visual
|
|
32
|
+
self.llm = self.core.language_model
|
|
33
|
+
self.vcfg, self.tcfg = self.vit.config, self.llm.config
|
|
34
|
+
self.image_token_id = base.config.image_token_id
|
|
35
|
+
self.vit_heads = self.vcfg.num_heads
|
|
36
|
+
self.vit_head_dim = self.vcfg.hidden_size // self.vcfg.num_heads
|
|
37
|
+
self.deepstack_layers = list(self.vit.deepstack_visual_indexes) # ViT layers whose output is added to early LLM layers
|
|
38
|
+
pe = self.vit.patch_embed
|
|
39
|
+
# changed: Conv3d (kernel == stride == patch) flattened once into a matrix for F.linear (see embed_patches)
|
|
40
|
+
self.patch_w = pe.proj.weight.detach().reshape(pe.embed_dim, -1)
|
|
41
|
+
self.patch_b = pe.proj.bias.detach()
|
|
42
|
+
|
|
43
|
+
def sync(self):
|
|
44
|
+
"""Wait for the device so wall-clock timings cover the device work."""
|
|
45
|
+
if self.device.type == "mps":
|
|
46
|
+
torch.mps.synchronize()
|
|
47
|
+
elif self.device.type == "cuda":
|
|
48
|
+
torch.cuda.synchronize(self.device)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def pick_device(name: str = "auto") -> str:
|
|
52
|
+
if name != "auto":
|
|
53
|
+
return name
|
|
54
|
+
if torch.cuda.is_available():
|
|
55
|
+
return "cuda"
|
|
56
|
+
return "mps" if torch.backends.mps.is_available() else "cpu"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def set_mps_memory_cap(cap_gb: float) -> float:
|
|
60
|
+
"""Cap this process's MPS allocations (useful on a shared 16 GB Mac). Returns the fraction set."""
|
|
61
|
+
frac = min(1.0, cap_gb * 2 ** 30 / torch.mps.recommended_max_memory())
|
|
62
|
+
torch.mps.set_per_process_memory_fraction(frac)
|
|
63
|
+
return frac
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
WEIGHT_STORAGE = ("native", "bf16", "bf16-llm-fp32")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_model(device: str = "auto", dtype: torch.dtype = torch.float16, lora: Optional[str] = None, weight_storage: str = "native",
|
|
70
|
+
model_id: str = MODEL_ID, lora_merge: bool = True) -> Model:
|
|
71
|
+
"""Load Qwen3-VL-Embedding-2B on the CPU, add an optional LoRA, then move it to `device`.
|
|
72
|
+
|
|
73
|
+
lora: PEFT LoRA state dict (.pt); None = zero-shot.
|
|
74
|
+
lora_merge=True: W += B @ A at load, so the compute graph equals the base model (paper default, fp16 / fp32).
|
|
75
|
+
lora_merge=False: keep the low-rank path separate, x W^T + (x A^T) B^T, as PEFT computes it (fp32 adapter path). Use this in bf16:
|
|
76
|
+
rounding W + B @ A to bf16 (8-bit mantissa) loses much of the low-rank update.
|
|
77
|
+
weight_storage="bf16" (CPU + float32 only): keep Linear weights in the checkpoint's bf16, compute in fp32 (store_linear_weights_bf16).
|
|
78
|
+
weight_storage="bf16-llm-fp32": as "bf16", then the LLM decoder-layer weights are upcast to fp32 once (ViT and the input-embedding
|
|
79
|
+
table stay bf16): no per-call upcast in the LLM, bitwise identical outputs, ~7 GB instead of ~4.3 GB.
|
|
80
|
+
"""
|
|
81
|
+
assert weight_storage in WEIGHT_STORAGE, weight_storage
|
|
82
|
+
device = pick_device(device)
|
|
83
|
+
bf16_store = weight_storage in ("bf16", "bf16-llm-fp32")
|
|
84
|
+
if bf16_store:
|
|
85
|
+
assert device == "cpu" and dtype == torch.float32, "bf16 weight storage is the CPU fp32 memory saver"
|
|
86
|
+
embedder = Qwen3VLEmbedder(model_id, dtype=torch.bfloat16 if bf16_store else dtype)
|
|
87
|
+
if lora and lora_merge and embedder.model.dtype == torch.bfloat16:
|
|
88
|
+
warnings.warn("merging the LoRA into bf16 weights rounds away part of the update; consider lora_merge=False (--no-lora-merge)")
|
|
89
|
+
if lora:
|
|
90
|
+
(merge_lora if lora_merge else attach_lora)(embedder.model, lora)
|
|
91
|
+
embedder.model.to(device).eval()
|
|
92
|
+
if bf16_store:
|
|
93
|
+
store_linear_weights_bf16(embedder.model)
|
|
94
|
+
if weight_storage == "bf16-llm-fp32": # changed: LLM weights upcast once at load (no per-call bf16 -> fp32 copy)
|
|
95
|
+
for p in embedder.model.model.language_model.layers.parameters():
|
|
96
|
+
p.data = p.data.float()
|
|
97
|
+
return Model(embedder=embedder, device=torch.device(device), dtype=dtype)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def merge_lora(model: torch.nn.Module, path: str, scale: float = 1.0) -> int:
|
|
101
|
+
"""Merge a PEFT LoRA state dict (alpha / r = 1 for the paper's r 32 / alpha 32) into the base weights: W += scale * B @ A in fp32.
|
|
102
|
+
|
|
103
|
+
Keys look like base_model.model.<module path>.lora_A.weight; the module path is relative to Qwen3VLForEmbedding.
|
|
104
|
+
"""
|
|
105
|
+
pairs = _lora_pairs(path)
|
|
106
|
+
for module, a, b in pairs:
|
|
107
|
+
w = model.get_submodule(module).weight
|
|
108
|
+
w.data = (w.data.float() + scale * (b.float() @ a.float())).to(w.dtype)
|
|
109
|
+
return len(pairs)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _lora_pairs(path: str):
|
|
113
|
+
"""(module path, A, B) per LoRA module of a PEFT state dict (keys base_model.model.<module path>.lora_A.weight), .safetensors or .pt."""
|
|
114
|
+
if str(path).endswith(".safetensors"):
|
|
115
|
+
from safetensors.torch import load_file
|
|
116
|
+
state = load_file(str(path), device="cpu")
|
|
117
|
+
else:
|
|
118
|
+
state = torch.load(path, map_location="cpu", weights_only=True)
|
|
119
|
+
pairs = [(key[len("base_model.model."):-len(".lora_A.weight")], a, state[key.replace(".lora_A.", ".lora_B.")])
|
|
120
|
+
for key, a in state.items() if key.endswith(".lora_A.weight")]
|
|
121
|
+
assert pairs and len(pairs) == len(state) // 2, f"unexpected LoRA state dict {path}"
|
|
122
|
+
return pairs
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class LoRALinear(torch.nn.Module):
|
|
126
|
+
"""nn.Linear + separate low-rank update, computed as PEFT does: A, B stay fp32 (PEFT upcasts bf16 / fp16 adapters), the input is
|
|
127
|
+
cast to fp32 for the low-rank path, base(x) + B(A(x)) * scale is summed in fp32 and cast back to the base output dtype.
|
|
128
|
+
dropout (training only, PEFT's lora_dropout) acts on the fp32 input of the low-rank path; it is the identity in eval mode."""
|
|
129
|
+
|
|
130
|
+
def __init__(self, base: torch.nn.Linear, a: torch.Tensor, b: torch.Tensor, scale: float = 1.0, dropout: float = 0.0):
|
|
131
|
+
super().__init__()
|
|
132
|
+
self.base, self.scale = base, scale
|
|
133
|
+
self.lora_a = torch.nn.Parameter(a.float(), requires_grad=False)
|
|
134
|
+
self.lora_b = torch.nn.Parameter(b.float(), requires_grad=False)
|
|
135
|
+
self.dropout = torch.nn.Dropout(dropout) if dropout else torch.nn.Identity()
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def weight(self): # expose the base weight (dtype / shape lookups by callers)
|
|
139
|
+
return self.base.weight
|
|
140
|
+
|
|
141
|
+
def forward(self, x):
|
|
142
|
+
y = self.base(x)
|
|
143
|
+
return (y + F.linear(F.linear(self.dropout(x.to(self.lora_a.dtype)), self.lora_a), self.lora_b) * self.scale).to(y.dtype)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def attach_lora(model: torch.nn.Module, path: str, scale: float = 1.0, dropout: float = 0.0) -> int:
|
|
147
|
+
"""Unmerged LoRA: replace each target nn.Linear by a LoRALinear (alpha / r = 1 for the paper's r 32 / alpha 32)."""
|
|
148
|
+
pairs = _lora_pairs(path)
|
|
149
|
+
for module, a, b in pairs:
|
|
150
|
+
parent, _, name = module.rpartition(".")
|
|
151
|
+
setattr(model.get_submodule(parent), name, LoRALinear(model.get_submodule(module), a, b, scale, dropout))
|
|
152
|
+
return len(pairs)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def lora_state(model: torch.nn.Module) -> dict:
|
|
156
|
+
"""PEFT-format state dict (base_model.model.<module path>.lora_A / lora_B.weight) of the attached LoRALinear modules: the file
|
|
157
|
+
format every --lora option reads."""
|
|
158
|
+
state = {}
|
|
159
|
+
for name, mod in model.named_modules():
|
|
160
|
+
if isinstance(mod, LoRALinear):
|
|
161
|
+
state[f"base_model.model.{name}.lora_A.weight"] = mod.lora_a.detach().cpu().clone()
|
|
162
|
+
state[f"base_model.model.{name}.lora_B.weight"] = mod.lora_b.detach().cpu().clone()
|
|
163
|
+
return state
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def store_linear_weights_bf16(model: torch.nn.Module) -> None:
|
|
167
|
+
"""CPU memory saver: nn.Linear weights (and the token-embedding table) stay bf16 and are upcast per call; everything else is fp32.
|
|
168
|
+
|
|
169
|
+
bf16 -> fp32 is exact, so outputs equal an fp32 model loaded from the bf16 checkpoint, at ~4.3 GB instead of ~8.5 GB resident.
|
|
170
|
+
changed: patches nn.Linear / nn.Embedding forward for the whole process (the upcast is inside every timed forward).
|
|
171
|
+
"""
|
|
172
|
+
nn = torch.nn
|
|
173
|
+
for mod in model.modules():
|
|
174
|
+
keep_bf16 = isinstance(mod, nn.Linear) or (isinstance(mod, nn.Embedding) and mod.weight.shape[0] > 100000)
|
|
175
|
+
for name, p in mod.named_parameters(recurse=False):
|
|
176
|
+
if p.is_floating_point() and not (keep_bf16 and name == "weight"):
|
|
177
|
+
p.data = p.data.float()
|
|
178
|
+
for name, b in mod.named_buffers(recurse=False):
|
|
179
|
+
if b.is_floating_point():
|
|
180
|
+
setattr(mod, name, b.float())
|
|
181
|
+
embedding_forward = nn.Embedding.forward
|
|
182
|
+
|
|
183
|
+
def linear_upcast(self, x):
|
|
184
|
+
w = self.weight
|
|
185
|
+
return F.linear(x, w if w.dtype == x.dtype else w.to(x.dtype), self.bias)
|
|
186
|
+
|
|
187
|
+
nn.Linear.forward = linear_upcast
|
|
188
|
+
nn.Embedding.forward = lambda self, x: embedding_forward(self, x).float()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def embed_patches(m: Model, pixels: torch.Tensor, linear: bool) -> torch.Tensor:
|
|
192
|
+
"""ViT patch embedding of flattened patches (n, 3*2*16*16)."""
|
|
193
|
+
if linear:
|
|
194
|
+
# changed: Conv3d patch embed -> F.linear (identical math; MPS Conv3d runs at ~0.02 TFLOP/s)
|
|
195
|
+
return F.linear(pixels.reshape(-1, m.patch_w.shape[1]).to(m.patch_w.dtype), m.patch_w, m.patch_b)
|
|
196
|
+
pe = m.vit.patch_embed
|
|
197
|
+
return type(pe).forward(pe, pixels) # stock HF Conv3d forward
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass
|
|
201
|
+
class Grid:
|
|
202
|
+
"""Constants of one (resolution, prompt) pair; identical for every frame of a stream, so computed once and cached."""
|
|
203
|
+
key: tuple
|
|
204
|
+
input_ids: torch.Tensor
|
|
205
|
+
vit_pos: torch.Tensor # learned ViT position embedding per patch
|
|
206
|
+
vit_cos: torch.Tensor # ViT 2-D RoPE per patch
|
|
207
|
+
vit_sin: torch.Tensor
|
|
208
|
+
token_emb: torch.Tensor # (L_full, H) LLM input embeddings of the prompt (image slots are overwritten by ViT features)
|
|
209
|
+
llm_pos: torch.Tensor # (rows, L_full) mRoPE position ids of the full-grid prompt
|
|
210
|
+
first: int # index of the first / last image token in the full-grid prompt
|
|
211
|
+
last: int
|
|
212
|
+
n_blocks: int # 880 for 1280x704
|
|
213
|
+
n_patches: int # 4 * n_blocks
|
|
214
|
+
length: int # L_full = prompt length with all blocks
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def text_tail(self) -> int:
|
|
218
|
+
"""Prompt tokens after the image (instruction end + final token)."""
|
|
219
|
+
return self.length - self.last - 1
|
|
220
|
+
|
|
221
|
+
def llm_len(self, n_kept: int) -> int:
|
|
222
|
+
"""LLM sequence length when only n_kept blocks are fed (Token Pruning drops the rest)."""
|
|
223
|
+
return self.first + n_kept + self.text_tail
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@torch.inference_mode()
|
|
227
|
+
def grid(m: Model, inputs: dict) -> Grid:
|
|
228
|
+
"""Position embeddings / RoPE / prompt embeddings, computed once per resolution.
|
|
229
|
+
changed: hoisted out of the per-frame forward (stock HF recomputes them every call)."""
|
|
230
|
+
thw, ids = inputs["image_grid_thw"], inputs["input_ids"]
|
|
231
|
+
key = (tuple(thw[0].tolist()), ids.shape[1])
|
|
232
|
+
g = m.grids.get(key)
|
|
233
|
+
if g is not None:
|
|
234
|
+
assert torch.equal(g.input_ids, ids), "same resolution and prompt length must mean the same prompt"
|
|
235
|
+
return g
|
|
236
|
+
V, cv = m.vit, m.vcfg
|
|
237
|
+
ii, iw = MQ.get_vision_interpolation_indices_and_weights(thw, num_grid_per_side=V.num_grid_per_side, mode=V.interpolation_mode,
|
|
238
|
+
align_corners=V.interpolation_align_corners,
|
|
239
|
+
spatial_merge_size=cv.spatial_merge_size, kwargs={})
|
|
240
|
+
pid = MQ.get_vision_position_ids(thw, V.spatial_merge_size, kwargs={})
|
|
241
|
+
pos = (V.pos_embed(ii) * iw[:, :, None]).sum(1)
|
|
242
|
+
cos, sin = V.rotary_pos_emb(pos, pid)
|
|
243
|
+
mm = inputs.get("mm_token_type_ids", (ids == m.image_token_id).int())
|
|
244
|
+
x0 = m.core.get_input_embeddings()(ids)
|
|
245
|
+
m.core.rope_deltas = None
|
|
246
|
+
p3 = m.core.compute_3d_position_ids(input_ids=ids, inputs_embeds=x0, image_grid_thw=thw, attention_mask=inputs["attention_mask"],
|
|
247
|
+
mm_token_type_ids=mm)
|
|
248
|
+
ipos = (ids[0] == m.image_token_id).nonzero()[:, 0]
|
|
249
|
+
first, last, n = int(ipos[0]), int(ipos[-1]), len(ipos)
|
|
250
|
+
assert last - first + 1 == n and int(thw.prod()) == 4 * n, "expected one contiguous run of image tokens, 4 patches each"
|
|
251
|
+
g = Grid(key=key, input_ids=ids, vit_pos=pos, vit_cos=cos, vit_sin=sin, token_emb=x0[0], llm_pos=p3[:, 0],
|
|
252
|
+
first=first, last=last, n_blocks=n, n_patches=4 * n, length=ids.shape[1])
|
|
253
|
+
m.grids[key] = g
|
|
254
|
+
return g
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Minimal Qwen3-VL-Embedding wrapper: processor + model + last-token pooling, images and text only.
|
|
2
|
+
|
|
3
|
+
Trimmed from Qwen's reference `qwen3_vl_embedding.py` (Apache-2.0), which ships with Qwen3-VL-Embedding. Kept identical where it matters
|
|
4
|
+
for the vectors: chat formatting, pixel limits, `process_vision_info(image_patch_size=16)`, processor call arguments, last-token pooling.
|
|
5
|
+
Removed: video inputs, token truncation helper, the automatic move to CUDA in __init__ (the caller picks the device).
|
|
6
|
+
The module tree is unchanged (`Qwen3VLForEmbedding.model` = HF `Qwen3VLModel`), so LoRA state-dict keys of the reference resolve as is.
|
|
7
|
+
"""
|
|
8
|
+
import unicodedata
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
import torch
|
|
12
|
+
import torch.nn.functional as F
|
|
13
|
+
from qwen_vl_utils.vision_process import process_vision_info
|
|
14
|
+
from transformers.models.qwen3_vl.modeling_qwen3_vl import Qwen3VLConfig, Qwen3VLModel, Qwen3VLPreTrainedModel
|
|
15
|
+
from transformers.models.qwen3_vl.processing_qwen3_vl import Qwen3VLProcessor
|
|
16
|
+
|
|
17
|
+
MAX_LENGTH = 8192
|
|
18
|
+
IMAGE_FACTOR = 32
|
|
19
|
+
MIN_PIXELS = 4 * IMAGE_FACTOR * IMAGE_FACTOR
|
|
20
|
+
MAX_PIXELS = 1800 * IMAGE_FACTOR * IMAGE_FACTOR
|
|
21
|
+
DEFAULT_INSTRUCTION = "Represent the user's input."
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Qwen3VLForEmbedding(Qwen3VLPreTrainedModel):
|
|
25
|
+
"""Qwen3VLModel without an LM head; the embedding is the final hidden state of the last token."""
|
|
26
|
+
_checkpoint_conversion_mapping = {} # (no `from __future__ import annotations` here: transformers reads `config`'s class)
|
|
27
|
+
accepts_loss_kwargs = False
|
|
28
|
+
config: Qwen3VLConfig
|
|
29
|
+
|
|
30
|
+
def __init__(self, config):
|
|
31
|
+
super().__init__(config)
|
|
32
|
+
self.model = Qwen3VLModel(config)
|
|
33
|
+
self.post_init()
|
|
34
|
+
|
|
35
|
+
def get_input_embeddings(self):
|
|
36
|
+
return self.model.get_input_embeddings()
|
|
37
|
+
|
|
38
|
+
def set_input_embeddings(self, value):
|
|
39
|
+
self.model.set_input_embeddings(value)
|
|
40
|
+
|
|
41
|
+
def forward(self, **inputs):
|
|
42
|
+
return self.model(**inputs)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Qwen3VLEmbedder:
|
|
46
|
+
def __init__(self, model_name_or_path: str, dtype: torch.dtype = torch.float32, max_length: int = MAX_LENGTH, local_files_only: bool = False,
|
|
47
|
+
min_pixels: int = MIN_PIXELS, max_pixels: int = MAX_PIXELS, revision: str | None = None):
|
|
48
|
+
self.max_length, self.min_pixels, self.max_pixels = max_length, min_pixels, max_pixels
|
|
49
|
+
self.model = Qwen3VLForEmbedding.from_pretrained(model_name_or_path, dtype=dtype, local_files_only=local_files_only,
|
|
50
|
+
revision=revision).eval()
|
|
51
|
+
self.processor = Qwen3VLProcessor.from_pretrained(model_name_or_path, padding_side="right", local_files_only=local_files_only,
|
|
52
|
+
revision=revision)
|
|
53
|
+
|
|
54
|
+
def without_model(self) -> "Qwen3VLEmbedder":
|
|
55
|
+
"""Processor-only shallow copy (cheap to send to DataLoader workers started with spawn, e.g. on macOS)."""
|
|
56
|
+
light = object.__new__(Qwen3VLEmbedder)
|
|
57
|
+
light.__dict__.update({k: v for k, v in self.__dict__.items() if k != "model"})
|
|
58
|
+
return light
|
|
59
|
+
|
|
60
|
+
def format_model_input(self, text: Optional[str] = None, image: Optional[str] = None, instruction: Optional[str] = None) -> list:
|
|
61
|
+
"""One chat conversation: system = instruction, user = [image] + [text]."""
|
|
62
|
+
if instruction:
|
|
63
|
+
instruction = instruction.strip()
|
|
64
|
+
if instruction and not unicodedata.category(instruction[-1]).startswith("P"):
|
|
65
|
+
instruction += "."
|
|
66
|
+
content = []
|
|
67
|
+
if image is not None:
|
|
68
|
+
content.append({"type": "image", "image": image if image.startswith(("http://", "https://")) else "file://" + image,
|
|
69
|
+
"min_pixels": self.min_pixels, "max_pixels": self.max_pixels})
|
|
70
|
+
if text is not None:
|
|
71
|
+
content.append({"type": "text", "text": text})
|
|
72
|
+
if not content:
|
|
73
|
+
content.append({"type": "text", "text": "NULL"})
|
|
74
|
+
return [{"role": "system", "content": [{"type": "text", "text": instruction or DEFAULT_INSTRUCTION}]},
|
|
75
|
+
{"role": "user", "content": content}]
|
|
76
|
+
|
|
77
|
+
def preprocess(self, conversations: List[list]) -> dict:
|
|
78
|
+
"""Conversations -> processor tensors (CPU)."""
|
|
79
|
+
text = self.processor.apply_chat_template(conversations, add_generation_prompt=True, tokenize=False)
|
|
80
|
+
images, _, video_kwargs = process_vision_info(conversations, image_patch_size=16, return_video_metadata=True,
|
|
81
|
+
return_video_kwargs=True)
|
|
82
|
+
return self.processor(text=text, images=images, videos=None, video_metadata=None, truncation=True, max_length=self.max_length,
|
|
83
|
+
padding=True, do_resize=False, return_tensors="pt", **video_kwargs)
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def pool_last(hidden: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
|
|
87
|
+
"""Hidden state of the last non-padding token of each row (right padding)."""
|
|
88
|
+
col = attention_mask.shape[1] - attention_mask.flip(dims=[1]).argmax(dim=1) - 1
|
|
89
|
+
return hidden[torch.arange(hidden.shape[0], device=hidden.device), col]
|
|
90
|
+
|
|
91
|
+
@torch.inference_mode()
|
|
92
|
+
def embed(self, conversations: List[list], normalize: bool = True) -> torch.Tensor:
|
|
93
|
+
"""Stock (unmodified HF) forward of a batch of conversations -> (B, 2048) embeddings."""
|
|
94
|
+
device = next(self.model.parameters()).device
|
|
95
|
+
inputs = {k: v.to(device) for k, v in self.preprocess(conversations).items()}
|
|
96
|
+
hidden = self.model.model(**inputs).last_hidden_state
|
|
97
|
+
e = self.pool_last(hidden, inputs["attention_mask"])
|
|
98
|
+
return F.normalize(e, p=2, dim=-1) if normalize else e
|
|
99
|
+
|
|
100
|
+
def embed_texts(self, texts: List[str], instruction: Optional[str] = None) -> torch.Tensor:
|
|
101
|
+
return self.embed([self.format_model_input(text=t, instruction=instruction) for t in texts])
|
|
102
|
+
|
|
103
|
+
def process(self, items: List[Dict[str, Any]]) -> torch.Tensor:
|
|
104
|
+
"""Compatibility with the reference API: items = [{"text"|"image"|"instruction": ...}]."""
|
|
105
|
+
return self.embed([self.format_model_input(text=i.get("text"), image=i.get("image"), instruction=i.get("instruction"))
|
|
106
|
+
for i in items])
|