SenseNova-U1: Unifying Multimodal Understanding and Generation with NEO-unify Architecture

1. 出发点 (Motivation)

过去几年 VLM (视觉语言模型) 一直处在两个被割裂的世界:

这种分裂带来三个具体问题:

  1. 表示空间不一致 — VE 训练的目标 (对比学习 / 蒸馏) 跟 VAE 的目标 (像素重建) 完全不同, 同一张图在两套向量空间里互不兼容。
  2. 双重信息瓶颈 — VE 是有损的 (语义抽象会扔掉细节), VAE 也是有损的 (8× / 16× 下采样压缩)。理解和生成各自走了一遍信息瓶颈。
  3. 架构臃肿 — 之前的统一模型 (Show-o, Janus, BAGEL 等) 即便共享 backbone, 仍然在两侧挂着各自的 tokenizer / diffusion head, 训练 pipeline 是拼出来的, 不是一体的。

论文的核心问题: 能不能彻底丢掉 VE 和 VAE, 让一个模型直接吃像素、直接吐像素, 在端到端的统一训练里把"看懂"和"画出来"两件事自然涌现?

SenseNova-U1 给的回答是: 把 NEO-unify 这条 native pixel-word 路线放大到 8B 和 30B-A3B 两种规模, 用 Mixture-of-Transformers 把两条流缝在一起。

2. 方法 (Method) — 高中生友好 + 数学严谨

核心思想 (类比)

想象你要让一个学生既能看懂一张照片 (理解) 又能凭描述画出一张照片 (生成)。两种主流做法:

"无需 VAE"意味着模型必须直接在原始像素上做扩散去噪, 这是过去十年扩散模型一直不敢做的事 (太大、太难训练) — JiT [Heinrich et al.] 和 NEO-unify 最近证明了像素空间扩散是可行的, U1 把这条线工程化到生产规模。

三种架构对比
Fig. 4 — 三条路线的对比。① 顺序架构 (VE+VAE 编码, VAE 解码) ② 并行架构 (两个 VAE 共存) ③ SenseNova-U1 的原生路线: 极轻的 Encoding/Decoding 层 + MoT 主干, 不用 VE, 不用 VAE。

2.1 近无损视觉接口 (Near-Lossless Visual Interface)

视觉端只用两层卷积做编码, 一层 MLP 做解码 — "近无损"意味着不再像 VAE 那样有 8× 重建瓶颈。

编码器: 两次卷积, 步长 16 和 2, 等效于把 32×32 像素压成一个 token (32× 下采样)。再加 GELU 激活、2D 位置编码:

repo/src/sensenova_u1/models/neo_unify/modeling_neo_vit.py:L117-L132 — NEOVisionEmbeddings.__init__

class NEOVisionEmbeddings(nn.Module):
    def __init__(self, config: NEOVisionConfig):
        super().__init__()
        self.embed_dim = config.hidden_size
        self.llm_embed_dim = config.llm_hidden_size[0]
        self.downsample_factor = int(1 / config.downsample_ratio[0])   # = 2
        self.patch_size = config.patch_size                            # = 16

        # 第一层: 把 16×16 像素 patch 卷成一个 embed_dim 向量
        self.patch_embedding = nn.Conv2d(
            in_channels=config.num_channels, out_channels=self.embed_dim,
            kernel_size=self.patch_size, stride=self.patch_size)
        # 第二层: 再 2× 下采样, 投到 LLM 隐藏维度
        self.dense_embedding = nn.Conv2d(
            in_channels=self.embed_dim, out_channels=self.llm_embed_dim,
            kernel_size=self.downsample_factor, stride=self.downsample_factor)
        self.gelu = nn.GELU()
        # 注: 论文 §3.1 说"2D sinusoidal positional encoding", 但代码这里用的是 2D RoPE
        # (apply_2d_rotary_pos_emb), 不是 sin-cos —— 这是论文-代码的一处不一致, 见 §4

两步卷积 stride 是 16 × 2 = 32, 所以 2048×2048 的图最终只有 (2048/32)² = 4096 个 token, 这个 32× 压缩比就是 §3 反复强调的"32× compression ratio"。

解码器: 论文写的是"MLP head 直接预测像素 patch", 实际放出来的代码里有三种解码器实现 (MLP / PixelShuffle / ConvDecoder), 并且 §3 Stage 5 末尾就明确说"未来工作: 把 MLP 替换成 PixelShuffle + Conv 以缓解格子伪影" — 而代码里这个"未来"已经发生了。

repo/src/sensenova_u1/models/neo_unify/modeling_fm_modules.py:L528-L550 — PatchDecoder_preps (实际发布的解码器之一)

class PatchDecoder_preps(nn.Module):
    def __init__(self):
        super().__init__()
        # H/32 -> H/16 (2× upscale)
        self.ps1 = nn.PixelShuffle(2)
        self.conv1 = nn.Conv2d(1024, 1024, kernel_size=3, padding=1)
        self.act1 = nn.GELU()
        # H/16 -> H/8 (2× upscale)
        self.ps2 = nn.PixelShuffle(2)
        self.conv2 = nn.Conv2d(256, 256, kernel_size=3, padding=1)
        # H/8 -> H (8× upscale)
        self.ps3 = nn.PixelShuffle(8)
        self.conv3 = nn.Conv2d(4, 3, kernel_size=3, padding=1)
    def forward(self, x):
        # x shape: [B, 4096, H/32, W/32]
        x = self.act1(self.conv1(self.ps1(x)))      # -> [B, 256, H/16, W/16]
        x = self.act2(self.conv2(self.ps2(x)))      # -> [B, 256, H/8, W/8]
        x = self.conv3(self.ps3(x))                 # -> [B, 3, H, W]
        return x

2.2 像素空间 Flow Matching

生成端没有 VAE, 那扩散去噪在哪做? 答: 直接在像素空间。

Rectified Flow 的线性插值定义"含噪样本":

模型 \(\hat{x}_\theta\) 学着从 \(z_t\) 预测干净的 \(x\), 然后转成"速度":

这个速度 \(v\) 就是 ODE 里 \(\frac{dz}{dt}\) 的预测。训练用 MSE 让 \(v_\theta\) 逼近真实速度 \(v_\star = \frac{x - z_t}{1-t}\):

数值小例子: 假设 \(t=0.6, x = 1.0, \epsilon = 0.0, \sigma_R = 1.0\), 则 \(z_t = 0.6\), \(v_\star = (1.0 - 0.6)/(1-0.6) = 1.0\)。直觉: 离 \(x\) 还差 \(0.4\), 但剩余时间也是 \(0.4\), 所以"速度"是 1.0。如果模型输出 \(v_\theta = 1.0\) 就完美。

2.3 分辨率自适应噪声尺度 \(\sigma_R\) — 论文最巧的一笔

问题: 直接在像素空间训练, 不同分辨率的图 token 数差好几倍 (1024² 是 1024 个 token, 2048² 是 4096 个 token)。如果都用标准 \(\mathcal{N}(0,I)\), 高分辨率图的噪声"总能量"会远大于信号能量, 信噪比 (SNR) 完全错配。

论文的做法: 让噪声的标准差随 token 数开根号增长, 保持每个 token 的噪声能量近似恒定

repo/src/sensenova_u1/models/neo_unify/modeling_neo_chat.py:L815-L822 — 推理时计算 σ_R

noise_scale = self.noise_scale                          # σ_0
if self.noise_scale_mode in ("resolution", "dynamic", 'dynamic_sqrt'):
    base = float(self.noise_scale_base_image_seq_len)   # N_0 (e.g. 64)
    # σ_R = σ_0 * √(N(H,W) / N_0), 正是论文 §3.1 Dynamic Noise Scale
    noise_scale = math.sqrt(
        (grid_h * grid_w) / (merge_size**2) / base
    ) * float(self.noise_scale)
    if self.noise_scale_mode == 'dynamic_sqrt':
        noise_scale = math.sqrt(noise_scale)            # 双重 √, 论文未提的额外模式
noise_scale = min(noise_scale, self.noise_scale_max_value)  # σ ≤ σ_max = 8
image_prediction = noise_scale * torch.randn(...)       # 初始 z_0 = σ_R · ε

注意: 代码里实际有三种模式 (resolution / dynamic / dynamic_sqrt), 论文只描述了开根号那一种。dynamic_sqrt 是开两次根号 (= 四次方根), 论文没解释为什么需要这个。

2.4 联合训练目标

整体训练 loss 是文本 next-token CE 和像素 flow matching MSE 的加权和:

论文 Table 2 显示 Stage 3 (统一中训练) 之后 \(\lambda_1 = 0.1, \lambda_2 = 1.0\) — 也就是 生成 loss 权重比理解 loss 大 10 倍。这暗示了一个工程现实: 生成更难收敛, 需要更多梯度。

2.5 Native MoT (Mixture-of-Transformers) 主干

这是 SenseNova-U1 跟之前统一模型最不一样的一笔。架构上是单个 Transformer, 但每一层里有两套独立的 Q/K/V 投影、Norm、FFN:

注意力掩码也是精心设计的混合模式:

混合注意力 mask
Fig. 6 — 统一多模态 prefill 的混合注意力模式: 文本行走因果, 图像行能 attend 到完整的 text prefix + 整个图像 span。serving 时只对包含 image token 的 M-block 扩展 K 范围, 纯文本 block 保留 vanilla FlashAttention3 快速路径。

MoT 在代码中的体现: 不是花式 routing 算子, 而是干脆造了两个并列的 vision model 实例, 用一个 gen_model 布尔开关挑哪个。

repo/src/sensenova_u1/models/neo_unify/modeling_neo_chat.py:L168-L170, L304-L307

# __init__ 里同时创建两个 vision model:
self.vision_model = NEOVisionModel(config.vision_config)        # understanding stream
vision_model_mot_gen = NEOVisionModel(config.vision_config)     # generation stream
...
self.fm_modules = nn.ModuleDict({
    "vision_model_mot_gen": vision_model_mot_gen,
    "timestep_embedder": TimestepEmbedder(llm_hidden_size),
    "fm_head": fm_head,
})

# 用 gen_model 标志位决定走哪个 stream:
def extract_feature(self, pixel_values, gen_model=False, grid_hw=None):
    if gen_model:
        return self.fm_modules['vision_model_mot_gen'](
            pixel_values=pixel_values, grid_hw=grid_hw,
            output_hidden_states=False, return_dict=True
        ).last_hidden_state
    # ... else 走 self.vision_model

2.6 模型规模

架构配置表
Table 1 — 两个变体的架构配置. 8B-MoT: dense, 42 层, 8.2B/8.2B (Und/Gen) — 两条流都是 8B。A3B-MoT: MoE, 48 层, Und 用 128 个专家 (共 30B, 推理时激活 8 个); Gen 用 32 个专家 (共 8B, 激活 8 个 = A3B 即"激活 3B")。

2.7 推理流程 (含 CFG 双引导)

推理时 ODE 用 Euler 解, \(z_{t+\Delta t} = z_t + \Delta t \cdot v_\theta\)。代码里就是一行:

repo/src/sensenova_u1/models/neo_unify/modeling_neo_chat.py:L397-L399, L915-L916

def _euler_step(self, v_pred, z, t, t_next):
    z_next = z + (t_next - t) * v_pred
    return z_next

# 在主循环里:
z = z + (t_next - t) * v_pred
image_prediction = self.unpatchify(z, self.patch_size * merge_size, ...)

Unified model 里 "空 prompt" 有两种

SD/SDXL 那种单一文本条件的 CFG 只需要一个 "uncond" (空字符串 / negative prompt)。但 SenseNova-U1 是 unified model, 输入是 (图像上下文, 文本指令) 两路条件, "空"得拆开做:

推理一开始就同时跑 3 次 prefill, 拿到三套 KV cache, 后续每个 Euler step 都复用它们做 forward:

repo/src/sensenova_u1/models/neo_unify/modeling_neo_chat.py:L682-L721 — 三套 KV cache 的构造

# 1. Condition cache (真实 prompt + 真实图像) → p(x | c_img, c_txt)
template_cond.append_message(roles[0], prompt)
query_cond = template_cond.get_prompt() + '\n\n\n\n'
query_cond = replace_image_tokens(query_cond, grid_hw)
past_key_values_cond = self.language_model(inputs_embeds=...).past_key_values

# 2. Text Uncondition cache (文本"空"但保留图像) → p(x | c_img)
# 关键: user message 只用 '' 占位符, 占住图像 token 位置, 不放任何文字指令
question_text_uncondition = '' * len(images)
template_tu.append_message(roles[0], question_text_uncondition)
past_key_values_tu = ...

# 3. Image Uncondition cache (文本和图像都空) → p(x)
query_img_uncond = self._build_t2i_query("", append_text=IMG_START_TOKEN)
past_key_values_iu = ...

注意 tu 不是真的空字符串 — user message 是 '<image>' * N, 占位符让图像 token 留在原位置。这样 "cond − tu" 才严格等于 "加上文本指令带来的边际贡献", 而不会同时混入"图像被移除"的扰动。

三分支 CFG 公式

三个 cache 各跑一次 forward, 得到三个速度预测 \(v_\text{cond}, v_\text{img-cond}, v_\text{uncond}\), 按论文 Eq. 6 组合:

拆开看两个差分项的几何意义:

论文推荐 \(\gamma = 4, \gamma_\text{img} = 1\)只放大文本, 不动图像。作者在 §3.3 解释: "model already captures visual conditioning effectively, while stronger guidance is primarily needed to enforce textual alignment" — unified 训练的副产品是模型对图像上下文已经"自然听话", 文本反而需要外加引导, 跟普通 latent diffusion 的情况相反。

代码实现的 4 个分支

实际代码并不总是跑完整的 3 路 CFG。根据 \(\gamma\)\(\gamma_\text{img}\) 的组合, 走不同的快速路径:

repo/src/sensenova_u1/models/neo_unify/modeling_neo_chat.py:L883-L902 — 三分支 CFG 推理

use_cfg = (t > cfg_interval[0] and t < cfg_interval[1]) or cfg_interval[0] == 0
out_cond = self._t2i_predict_v(image_embeds, indexes_image_condition, ...)
if not use_cfg or (cfg_scale == 1 and img_cfg_scale == 1):
    v_pred = out_cond                                        # ① 不开 CFG, 1 次 forward

elif img_cfg_scale == 1:                                     # ② 单 CFG, 2 次 forward (默认 T2I 走这里)
    out_img_cond = self._t2i_predict_v(image_embeds, indexes_image_text_uncondition, ...)
    v_pred = out_img_cond + cfg_scale * (out_cond - out_img_cond)
    # 等价于 (1-γ)·tu + γ·cond, 只放大文本贡献, 图像保持自然

elif cfg_scale == img_cfg_scale:                             # ③ 经典 CFG, 2 次 forward
    out_uncond = self._t2i_predict_v(image_embeds, indexes_image_img_uncondition, ...)
    v_pred = out_uncond + cfg_scale * (out_cond - out_uncond)
    # 退化成 SD 那样的单维度 CFG: cond vs full-uncond

else:                                                        # ④ 双 CFG, 3 次 forward (Eq.6 全形)
    out_img_cond = self._t2i_predict_v(image_embeds, indexes_image_text_uncondition, ...)
    out_uncond = self._t2i_predict_v(image_embeds, indexes_image_img_uncondition, ...)
    v_pred = (out_uncond
              + cfg_scale * (out_cond - out_img_cond)
              + img_cfg_scale * (out_img_cond - out_uncond))

默认 T2I 的参数 (cfg_scale=4.0, img_cfg_scale=1.0) 走的是 分支 ②, 每步只需 2 次 forward, iu 那一路 cache 虽然算了但没用到 (浪费? 是, 但代码统一这么写)。

不同任务下三个 cache 的差异

任务condtuiu典型分支
纯 T2I prompt + 无输入图 空 prompt + 无输入图 空 prompt + 无输入图 ② (tu 与 iu 等价)
图像编辑 编辑指令 + 源图 空指令 + 保留源图 空指令 + 无图 ④ (γ 控制指令执行力度, γimg 控制对原图保留度)
Interleaved 生成 当前 prompt + 已生成的图文上下文 空指令 + 上下文 空指令 + 无上下文 ② / ④ (每生成一张图重算三个 cache)

关于 negative prompt

代码没有暴露 negative prompt 接口。三个 cache 都是"unconditional"的对照点, 不是反向引导。架构上是支持的 — 把 tu 那一支的 question_text_uncondition 从占位符换成 "ugly, lowres, distorted" 就能拿到 negative prompt 效果 — 但官方没把这个开关放出来。这与 SD/SDXL 生态里 negative prompt 是一等公民的做法不同, 推测原因: unified 训练时 conditioning drop 用的是空字符串 (论文 §3.3: 10% drop text, 额外 10% drop both), 模型在训练时没见过"反向描述", 直接换成 negative prompt 行为可能不稳定。

2.8 训练 Recipe (6 阶段)

整套训练流水线是6 个 stage, 不是单次 end-to-end:

  1. Stage 1: Understanding warmup — 初始化自 NEO, 先做 attention-fusion (合并 Q/K projection), 再 full-model 继续训练。
  2. Stage 2: Generation pre-training (3 phases) — 冻结理解分支, 单训生成分支。Phase I 256²→512², Phase II 512²→2048², Phase III 加入 editing/interleaved 数据。
  3. Stage 3: Unified mid-training — 全量 joint training, 84K steps, \(\lambda_1{:}\lambda_2 = 0.1{:}1.0\)。数据配比 Und : Gen : Edit : Interleave = 0.33 : 0.37 : 0.24 : 0.06。
  4. Stage 4: Unified SFT — 9K steps, cosine 衰减, 同样的 loss 权重。
  5. Stage 5: RL post-training — Flow-GRPO + 三个 reward (OCR IoU / VLM style judge / HPSv3 美学)。
  6. Stage 6: DMD2 蒸馏 — 把 100 NFE 蒸到 8 NFE, 推理大幅加速。

整个训练大约 3.5T tokens (0.75T + 0.25T + 0.25T + 0.88T + 1.19T + 0.13T)。

2.9 推理基础设施

解耦推理架构
Fig. 5 — 解耦部署: 理解和生成跑在两个独立引擎里 (LightLLM 处理多模态理解+文本流, LightX2V 处理图像生成), 通过 pinned shared memory 交换状态。可分离 (Separate) 部署或共置 (Colocate) 部署。在 2048×2048 生成时, TP2+CFG2 配置下 5090 GPU 每步 0.415s, L40S 每步 0.443s。

3. 结论 (Key Findings)

3.1 理解 / 推理 / 文档 OCR

多模态理解 benchmark
Tab. 3 — 多模态理解 benchmark. SenseNova-U1 (8B/A3B) 在没有专门做理解 RL 的情况下, 对标 Qwen3VL-8B / Qwen3.5-9B (同样 LLM backbone) 全面对手, 在数学推理、空间智能、视觉中心推理上明显占优。

具体数字 (8B-Think / A3B-Think):

意外: 空间智能上的提升幅度远大于一般 VQA — 论文归因为"端到端像素表示保留了几何细节, 不被 VE 抹平"。

3.2 文本理解 / 指令遵循 / Agent

多模态训练没有损坏纯文本能力, 反而在指令遵循上比纯文本 baseline 还好 (论文未给出具体原因)。

3.3 图像生成 — GenEval 单项冠军

GenEval 结果
Tab. 5 — GenEval 组合生成评测. SenseNova-U1 (8B 和 A3B) 都达到 0.91 总分, 超过 GPT-Image-1 / Seedream 4.0 / Seedream 3.0 (均为 0.84) 这些闭源模型, 也超过 NEO-unify (0.90), Qwen-Image (0.87), BAGEL (0.82), FLUX.1-dev (0.66)。

3.4 像素空间压缩对重建质量的影响

重建质量对比
Fig. 10 / Tab. 23 — 32× 压缩比下的重建质量. NEO-unify (2B) 在 32× 下达到 PSNR 31.56 / SSIM 0.85 — 跟 FLUX.1-dev VAE 在 8× 下的 31.56/0.93 几乎一致。论文用这个证据反驳"高压缩会丢细节"的传统认知: 在端到端框架下, 32× 压缩也能保留语义和像素细节。

3.5 数据规模效率

论文 §5.2.3 提到 Stage 3 在约 80M 样本时40K steps 就接近收敛 (总训练是 84K steps), 暗示这个 native 架构的数据效率比模块化 UMM 更高 — 但论文没给出严格的对比曲线。

4. 实现细节 (Implementation Notes)

这一节专门记**代码里关键、但论文未充分说明 (或与论文不一致)**的工程细节。

4.1 论文 vs 代码 — 明确的不一致

4.2 默认推理超参 (来自 examples)

repo/examples/t2i/inference.py:L215-L234

p.add_argument("--cfg_scale", type=float, default=4.0)        # 论文 γ=4 ✓
p.add_argument("--cfg_norm", default="none",
               choices=["none", "global", "channel", "cfg_zero_star"])
p.add_argument("--timestep_shift", type=float, default=3.0)    # 论文 §3.3 时移=3.0 ✓
p.add_argument("--cfg_interval", type=float, nargs=2, default=[0.0, 1.0])
p.add_argument("--num_steps", type=int, default=50)            # 论文 100→8 DMD, 默认 50
p.add_argument("--seed", type=int, default=42)

4.3 一个论文没强调的实际能力: 外挂 prompt enhancer

repo/examples/t2i/inference.py:L283-L298

p.add_argument("--enhance", action="store_true", help=(
    "Run the user prompt through an LLM enhancer before T2I inference. "
    "Helpful for short / loose prompts, especially infographic-style "
    "generation. Configure via U1_ENHANCE_{BACKEND,ENDPOINT,API_KEY,MODEL} "
    "env vars; defaults target Gemini 3.1 Pro."
))

注意: 默认的 enhance 后端是 Gemini 3.1 Pro (Google 的模型!), 而不是 SenseNova 自家的模型。意味着论文里展示的"复杂 infographic 生成"等惊艳例子, 实际部署时可能依赖一个外部 LLM 把用户 prompt 重写得更详细。论文里没强调这一点。

4.4 KV cache 复用

推理时, prefill 阶段对 understanding stream 算出来的 KV 同时给生成 stream 用 (它们共享 self-attention)。prepare_flash_kv_cache (modeling_neo_chat.py:L30-L79) 一次性把 prefix cache 转成 flash-attn 友好的 [B, S, H, D] 布局, 然后预分配 prefix + denoise_steps 大小的整块, 后续每步直接 in-place 写。这是**论文 §3.5 提到的"生成阶段 KV 由理解模块提供"**的实际实现。

4.5 推理优化清单

5. 批判性总结 (Critical Assessment)

5.1 优点

5.2 不足 / 疑点

5.3 适用 vs 不适用

5.4 进一步阅读


讨论 / Comments

评论托管在本仓库的 GitHub Discussions, 需 GitHub 账号。