Skip to content

[Bug] Embedding 训练开启 padding_free 后,单 Token 序列可能被错误合并,导致 InfoNCE 样本错位 / zero negative 问题描述 #9903

Description

@dog-qiuqiu

Checklist / 检查清单

  • I have searched existing issues, and this is a new bug report. / 我已经搜索过现有的 issues,确认这是一个新的 bug report。

Bug Description / Bug 描述

[Bug] Embedding 训练开启 padding_free 后,单 Token 序列可能被错误合并,导致 InfoNCE 样本错位 / zero negative

问题描述

在使用 Qwen3-Embedding 进行训练,并开启:

--task_type embedding
--loss_type infonce
--padding_free true

时,如果某些 embedding 输入在模板编码后只有 1 个 tokenpadding_free 在恢复原始 sequence 边界时可能发生错误。

结果表现为:

  • 实际恢复出的 sentence embedding 数量少于真实逻辑 sequence 数量;
  • 轻微情况下训练不会报错,但 embedding 与 labels 可能已经错位;
  • 严重情况下会触发:
RuntimeError: InfoNCE zero negative: rank=7, group=1

目前看问题出在 revert_padding_free() 对 sequence boundary 的恢复逻辑。


环境信息

ms-swift: 3.10.1
task_type: embedding
model: Qwen3-Embedding
loss_type: infonce
hard_negatives: 256
per_device_train_batch_size: 2
padding_free: true

每条训练样本包含:

1 anchor
1 positive
256 negatives

因此每个 local batch 理论上应该包含:

2 × (1 + 1 + 256) = 516 条 logical sequences

InfoNCE labels 数量为:

2 × (1 positive + 256 negatives) = 514

实际报错

出错 batch 中打印到:

========== INFONCE ZERO NEGATIVE ==========
rank=7
group_index=1
hard_negatives=256
labels_shape=(514,)
sentences_shape=(260, 2560)
raw_split_indices=[0, 257]
start=258, end=516
split_part_len=2
label_values=[(0.0, 512), (1.0, 2)]
===========================================

理论上应该是:

sentences_shape=(516, 2560)
labels_shape=(514,)

但实际变成:

sentences_shape=(260, 2560)
labels_shape=(514,)

也就是说:

labels 数量仍然正确,但 sentence embeddings 已经从 516 条错误减少到了 260 条。


根因分析

Embedding 训练中,_embedding_data_collator() 会把每条样本展开为独立 sequence:

anchor
positive
negative0
negative1
...
negative255

开启 padding_free=true 后,_data_collator() 会执行:

batch[:] = [self.packing_row(batch)]

将所有 sequence flatten 成一条 token stream。

packing_row()position_ids 的构造逻辑为:

packed['position_ids'] = sum(
    (list(range(x)) for x in length),
    start=[]
)

例如三条 sequence 长度为:

5, 1, 3

会生成:

0 1 2 3 4 | 0 | 0 1 2

revert_padding_free() 当前使用:

pos = position_ids[0]
resets = torch.where(pos[1:] < pos[:-1])[0] + 1

来判断新 sequence 的边界。

如果前一条 sequence 长度为 1:

seq A: [0]
seq B: [0, 1, 2]

flatten 后:

0 | 0 1 2

真实 sequence 边界是:

0 -> 0

但当前代码判断:

0 < 0  # False

因此无法识别该边界,最终会把两个独立 sequence 错误合并。


实际现场证据

我在 embedding collator 中增加了 debug 日志。

某个普通训练 batch 中得到:

========== PADDING_FREE BOUNDARY MISMATCH ==========
rank=2
zero_count=516
detected_seq_count=504
position_ids_shape=(1, 1813)
total_tokens=1813
====================================================

其中 zero_count=516 很关键。

因为 packing_row() 对每一个 logical sequence 都会从:

position_id = 0

开始,所以:

zero_count=516

说明该 batch 实际包含:

516 条 logical sequences

但当前 revert_padding_free() 的边界识别逻辑只识别出:

504 条

因此这个 batch 已经静默丢失了:

516 - 504 = 12 个 sequence boundary

实际 position_ids 中也可以直接看到:

..., 0, 1, 2, 3, 4, 0, 0, 1, 2, ...
                       ^  ^

这里对应:

... | 一个 1-token sequence | 下一个 sequence ...

中间的:

0 -> 0

无法被:

pos[1:] < pos[:-1]

检测到。


为什么这个问题可能比 crash 更严重

这个问题并不一定每次都会报错。

例如:

logical sequences: 516
recovered sequences: 504

此时 InfoNCE 中仍然可能存在大量 negative,因此训练可以继续进行。

但这意味着:

sentence embeddings 数量

已经和:

labels / group boundary

不再严格对应。

也就是说可能出现:

训练 loss 正常、训练继续执行,但实际 positive / negative 的 embedding 分组已经发生错位。

只有在极端 batch 中,丢失的 sequence 足够多,才会最终触发:

InfoNCE zero negative

本次出错 batch 为:

expected: 516
actual:   260
difference: 256

而:

hard_negatives=256

两者完全一致。


最小复现

无需完整 embedding 数据集即可复现这个边界问题。

例如:

position_ids = torch.tensor([
    [0, 1, 2, 0, 0, 1, 2]
])

它实际表示三条 sequence:

seq0 = [0, 1, 2]
seq1 = [0]
seq2 = [0, 1, 2]

但当前逻辑:

pos = position_ids[0]
resets = torch.where(pos[1:] < pos[:-1])[0] + 1

detected_seq_count = 1 + len(resets)
print(detected_seq_count)

输出:

2

而真实 sequence 数应该是:

3

期望行为

padding_free 应该完整保留 logical sequence boundary,而不应受到 sequence 长度影响。

对于 embedding 训练:

516 条 logical sequences

无论其中是否存在 1-token sequence,最终都应该恢复为:

516 条 sentence embeddings

而不是:

504
260
...

修复建议

建议不要仅通过:

pos[1:] < pos[:-1]

position_ids 反推 sequence boundary。

因为对于:

... | [0] | [0, 1, 2] | ...

这种情况,仅通过 position ID 无法利用严格下降条件恢复真实边界。

更可靠的方式是直接使用 packing / FlashAttention 阶段已经存在的显式 sequence boundary 信息,例如:

cu_seq_lens_q

或在 packing_row() 阶段显式保留每条 sequence 的长度 / cumulative offsets,在 revert_padding_free() 中直接使用。

不建议简单把:

<

改成:

<=

因为还需要确认:

  • multimodal position ids
  • mRoPE
  • sequence parallel
  • 其他可能出现重复 position id 的场景

是否会受到影响。


临时规避方案

关闭:

--padding_free false

即可绕过该问题。

关闭后,每个:

anchor / positive / negative

都会保持为独立 batch row,不需要通过 position_ids 再恢复 sequence 边界。


相关代码

Embedding 展开:

def _embedding_data_collator(...):
    ...
    indexes = ['anchor_', 'positive_']
    if max_neg is not None:
        for i in range(0, max_neg):
            indexes.append(f'negative{i}_')

    for prefix in indexes:
        new_batch += self._fetch_inputs_startswith([b], prefix)

    res = self._data_collator(new_batch, padding_to=padding_to)

Padding-free flatten:

if self.padding_free:
    batch[:] = [self.packing_row(batch)]

Position IDs 构造:

packed['position_ids'] = sum(
    (list(range(x)) for x in length),
    start=[]
)

当前 sequence 恢复逻辑:

pos = position_ids[0]
resets = torch.where(pos[1:] < pos[:-1])[0] + 1

当一个 1-token sequence 后面紧跟下一条 sequence 时,会出现:

0 -> 0

从而导致 sequence boundary 丢失。

How to Reproduce / 如何复现

参考问题描述

Additional Information / 补充信息

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions