File size: 14,939 Bytes
0bdbf4d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
#!/usr/bin/env python
"""
Shared-queue worker for OVO-Bench agentic evaluation.

设计目标
========
- 4 个 worker(每卡 1 个 TP=1 vLLM 实例)动态共享同一份 sample 列表;
- 用 POSIX O_EXCL 文件原子创建做认领(无外部依赖、NFS-safe);
- 每 worker 每次认领 K 个样本, 跑完再认领下一批 → 真动态负载均衡,
  不存在"长样本扎堆某 worker 拖尾, 其它卡闲置"的问题;
- 复用 agent.process_videos_agent 现有逻辑(in-flight 池仍生效),
  本脚本只是把 samples 列表替换成"动态认领的子集";
- 每 worker 写独立的 *.worker<N>.jsonl, 由 shell 脚本最后合并。

CLI
===
继承 agent.py 的所有参数, 额外新增:
  --worker_id N        本 worker 编号(0-based, 仅用于日志/claim 文件标记)
  --num_workers N      worker 总数(仅用于日志)
  --claims_dir PATH    共享认领目录(每个 sample 一个 .claim 文件)
  --claim_batch_size K 每批认领样本数(默认 16)
  --skip_score         跳过末尾 score_results(由合并脚本统一打分)
"""

from __future__ import annotations
import argparse
import json
import os
import sys
import time
from typing import List, Set

# 让脚本在仓库根/scripts 下都能 import 到 agent / video_processor
HERE = os.path.dirname(os.path.abspath(__file__))
if HERE not in sys.path:
    sys.path.insert(0, HERE)

import agent  # noqa: E402
import video_processor as ovo  # noqa: E402
from agent import (  # noqa: E402
    Budget,
    build_default_registry,
    build_extended_registry,
    process_videos_agent,
    render_agent_system,
)


# ----------------------------------------------------------------------------
# 原子认领
# ----------------------------------------------------------------------------
def atomic_claim_batch(
    claims_dir: str,
    candidates: List[str],
    batch_size: int,
    worker_id: int,
) -> List[str]:
    """对 ``candidates`` 顺序尝试 O_EXCL 创建 .claim 文件,
    成功的 id 计为本 worker 认领。最多认领 ``batch_size`` 个。
    """
    os.makedirs(claims_dir, exist_ok=True)
    claimed: List[str] = []
    for sid in candidates:
        if len(claimed) >= batch_size:
            break
        path = os.path.join(claims_dir, f"{sid}.claim")
        try:
            fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
            try:
                os.write(
                    fd,
                    (
                        f"worker={worker_id}\n"
                        f"pid={os.getpid()}\n"
                        f"ts={time.time()}\n"
                    ).encode(),
                )
            finally:
                os.close(fd)
            claimed.append(sid)
        except FileExistsError:
            continue
    return claimed


def load_global_processed_ids(output_dir: str, run_tag: str) -> Set[str]:
    """读所有 worker 已写出的 jsonl, 收集已完成的 sample id 集合。"""
    import glob as _g
    done: Set[str] = set()
    output_prefix = os.environ.get("STREAMO_OUTPUT_PREFIX", "ovobench")
    for fp in _g.glob(os.path.join(
        output_dir, f"{output_prefix}_{run_tag}.worker*.jsonl"
    )):
        try:
            with open(fp) as f:
                for line in f:
                    line = line.strip()
                    if not line:
                        continue
                    try:
                        r = json.loads(line)
                        sid = r.get("id")
                        if sid is not None:
                            done.add(str(sid))
                    except json.JSONDecodeError:
                        continue
        except FileNotFoundError:
            continue
    return done


def pending_claims(
    claims_dir: str,
    all_ids: List[str],
    done: Set[str],
) -> List[str]:
    return [
        sid
        for sid in all_ids
        if sid not in done
        and os.path.exists(os.path.join(claims_dir, f"{sid}.claim"))
    ]


def claim_owner_alive(claim_path: str):
    """Return True/False for PID-aware claims, None for legacy claims."""
    try:
        with open(claim_path, encoding="utf-8") as handle:
            text = handle.read()
    except OSError:
        return None
    pid = None
    for line in text.splitlines():
        if line.startswith("pid="):
            try:
                pid = int(line.split("=", 1)[1])
            except ValueError:
                return None
            break
    if pid is None:
        return None
    try:
        os.kill(pid, 0)
    except ProcessLookupError:
        return False
    except PermissionError:
        return True
    return True


# ----------------------------------------------------------------------------
# CLI(扩展 agent 的 argparser)
# ----------------------------------------------------------------------------
def _build_argparser() -> argparse.ArgumentParser:
    p = agent._build_argparser()
    p.description = "OVO-Bench shared-queue agent worker."
    g = p.add_argument_group("shared-queue worker options")
    g.add_argument("--worker_id", type=int, required=True,
                   help="0-based worker index, used in log + claim record")
    g.add_argument("--num_workers", type=int, default=4,
                   help="total worker count (informational only)")
    g.add_argument("--claims_dir", required=True,
                   help="shared directory holding <id>.claim atomic stubs")
    g.add_argument("--claim_batch_size", type=int, default=16,
                   help="number of samples each worker claims per round")
    g.add_argument("--run_tag", required=True,
                   help="run tag used for partial output filename")
    g.add_argument("--output_dir", required=True,
                   help="directory to write <prefix>_<tag>.worker<id>.jsonl")
    g.add_argument("--output_prefix", default="ovobench",
                   help="result shard filename prefix")
    g.add_argument("--skip_score", action="store_true",
                   help="skip ovo.score_results at end (merge step does it)")
    return p


# ----------------------------------------------------------------------------
# Main
# ----------------------------------------------------------------------------
def main():
    args = _build_argparser().parse_args()
    os.environ["STREAMO_OUTPUT_PREFIX"] = args.output_prefix

    # 1) 引擎初始化(同 agent.main, 保持 TP/gpu_mem 等行为一致)
    from swift import VllmEngine
    # Ascend NPU 上 torch.compile / aclgraph 捕获不稳定(qwen3_5 在 aot_compile
    # 阶段报 dynamo TypeError),默认走 enforce_eager;可用 STREAMO_ENFORCE_EAGER=0 关闭。
    _enforce_eager = os.environ.get("STREAMO_ENFORCE_EAGER", "1") not in ("0", "false", "False")
    # Qwen3.5 混合架构的 GDN(gated delta net) prefill 内核,vLLM 默认(auto)在 SM90(Hopper)
    # 上选 flashinfer JIT 内核;但 flashinfer 0.6.6 打包的 SM90 模板有编译期 bug
    # (flat::Unused 缺 consumer_wait/index 等成员 → Ninja/nvcc 编译失败),warmup 在
    # try/except 里被吞成 WARNING,worker 反复编译空耗 GPU 起不来。改走 triton 后端
    # (forward_native,无需 nvcc JIT)绕开。可用 STREAMO_GDN_PREFILL_BACKEND 覆盖。
    _gdn_backend = os.environ.get("STREAMO_GDN_PREFILL_BACKEND", "triton").strip().lower()
    _engine_kwargs = {}
    if _gdn_backend in ("triton", "flashinfer"):
        _engine_kwargs["gdn_prefill_backend"] = _gdn_backend
    print(f"[worker{args.worker_id}] gdn_prefill_backend={_gdn_backend or 'auto(default)'}")
    engine = VllmEngine(
        args.model,
        max_model_len=65536,
        limit_mm_per_prompt={"image": 600, "video": 1},
        tensor_parallel_size=args.tp,
        enable_prefix_caching=True,
        gpu_memory_utilization=args.gpu_memory_utilization,
        enforce_eager=_enforce_eager,
        engine_kwargs=_engine_kwargs,
    )
    if args.no_think:
        try:
            engine.template.enable_thinking = False
            print(f"[worker{args.worker_id}] no_think enabled")
        except Exception as e:
            print(f"[worker{args.worker_id}] no_think failed: {e}")

    # 2) 加载完整 samples + task_filter
    ovo.VIDEO_PREFIX = args.video_prefix
    samples = ovo.load_dataset_json(args.dataset)
    task_set = ovo._normalize_task_filter(args.tasks)
    if task_set:
        samples = [s for s in samples if s.get("task") in task_set]
    sample_by_id = {str(s["id"]): s for s in samples}
    all_ids: List[str] = [str(s["id"]) for s in samples]
    print(f"[worker{args.worker_id}] loaded {len(samples)} samples "
          f"(tasks={sorted(task_set) if task_set else 'all'})")

    # 3) registry / system / budget 工厂(与 agent.main 一致)
    use_extended = (
        args.memory_mode == "brief"
        or args.enable_memory_tools
        or args.enable_track_tools
        or args.enable_audio_tools
        or args.enable_efficiency_tools
    )
    if use_extended:
        registry = build_extended_registry(
            enable_memory=args.enable_memory_tools or args.memory_mode == "brief",
            enable_track=args.enable_track_tools,
            enable_audio=args.enable_audio_tools,
            enable_efficiency=args.enable_efficiency_tools or args.memory_mode == "brief",
        )
    else:
        registry = build_default_registry()
    base_system = ovo.SYSTEM if hasattr(ovo, "SYSTEM") else ""
    system = (
        base_system if args.no_tools
        else render_agent_system(registry, base_system, memory_mode=args.memory_mode,
                                  delta_input_mode=getattr(args, 'delta_input_mode', '2frame'))
    )

    def _budget_factory():
        return Budget(
            max_tool_calls=args.budget_max_tool_calls,
            max_frames_seen=args.budget_max_frames_seen,
            max_tokens=args.budget_max_tokens,
        )

    # 4) 输出文件:每 worker 自己一份分片
    os.makedirs(args.output_dir, exist_ok=True)
    out_path = os.path.join(
        args.output_dir,
        f"{args.output_prefix}_{args.run_tag}.worker{args.worker_id}.jsonl",
    )
    print(f"[worker{args.worker_id}] output → {out_path}")

    # 5) 共享队列主循环
    rounds_done = 0
    samples_done = 0
    claim_wait_started = None
    claim_wait_seconds = float(os.environ.get(
        "STREAMO_CLAIM_WAIT_SECONDS", "21600"
    ))
    while True:
        # 全局已完成(任一 worker 写出过) → 不再认领
        global_done = load_global_processed_ids(args.output_dir, args.run_tag)
        # 候选 = 全部样本 - 全局已完成 - 已被任何 worker 认领过的
        candidates = [
            sid for sid in all_ids
            if sid not in global_done
            and not os.path.exists(os.path.join(args.claims_dir, f"{sid}.claim"))
        ]
        if not candidates:
            pending = pending_claims(args.claims_dir, all_ids, global_done)
            if pending:
                now = time.time()
                claim_wait_started = claim_wait_started or now
                owners = [
                    claim_owner_alive(
                        os.path.join(args.claims_dir, f"{sid}.claim")
                    )
                    for sid in pending
                ]
                elapsed = now - claim_wait_started
                if elapsed < claim_wait_seconds and any(
                    owner is not False for owner in owners
                ):
                    print(
                        f"[worker{args.worker_id}] waiting for "
                        f"{len(pending)} claimed samples "
                        f"(alive={owners.count(True)}, "
                        f"legacy={owners.count(None)}, "
                        f"elapsed={elapsed:.0f}s)"
                    )
                    time.sleep(5)
                    continue
                print(
                    f"[worker{args.worker_id}] pending claims did not "
                    f"complete before exit: {pending[:12]}"
                )
            else:
                print(
                    f"[worker{args.worker_id}] no candidates or pending "
                    f"claims left; exit. rounds={rounds_done} "
                    f"samples={samples_done}"
                )
            break
        claim_wait_started = None

        claimed_ids = atomic_claim_batch(
            args.claims_dir, candidates, args.claim_batch_size, args.worker_id,
        )
        if not claimed_ids:
            # 这一轮全被别的 worker 抢走了, 等一下再扫
            time.sleep(2)
            continue

        sub_samples = [sample_by_id[i] for i in claimed_ids]
        rounds_done += 1
        samples_done += len(sub_samples)
        print(f"[worker{args.worker_id}] round {rounds_done}: "
              f"claimed {len(sub_samples)} samples → "
              f"{[s.get('task')+'/'+str(s.get('id')) for s in sub_samples[:6]]}"
              f"{' ...' if len(sub_samples)>6 else ''}")

        # 调用现有 process_videos_agent 跑这一批
        # 注意:它内部会 a-mode open(out_path), 直接 append; 多 worker
        # 写不同 jsonl 文件, 所以不会冲突。
        process_videos_agent(
            engine=engine,
            samples=sub_samples,
            system=system,
            registry=registry,
            max_concurrent=args.max_concurrent,
            batch_size=args.batch_size,
            output_file=out_path,
            task_filter=None,  # 已在外层过滤过, 这里禁用避免误删
            max_tool_calls_per_round=args.max_tool_calls_per_round,
            max_tool_calls_per_sample=args.max_tool_calls_per_sample,
            max_tool_calls_per_reply=args.max_tool_calls_per_reply,
            memory_mode=args.memory_mode,
            memory_token_budget=args.memory_token_budget,
            budget_factory=_budget_factory,
            trajectory_dir=args.trajectory_dir,
            auto_anchor_every=args.auto_anchor_every,
            auto_asr_every=args.auto_asr_every,
            auto_asr_window=args.auto_asr_window,
            auto_track_query_every=args.auto_track_query_every,
            memory_render_mode=args.memory_render_mode,
            memory_head_max=args.memory_head_max,
            memory_tail_max=args.memory_tail_max,
            memory_head_seconds=args.memory_head_seconds,
            memory_tail_seconds=args.memory_tail_seconds,
            memory_recall_topk=args.memory_recall_topk,
            cold_start_rounds=args.cold_start_rounds,
            degrade_threshold=args.degrade_threshold,
            degrade_events_min=args.degrade_events_min,
            rolling_window=args.rolling_window,
            delta_input_mode=getattr(args, 'delta_input_mode', '2frame'),
            enable_verification=getattr(args, 'enable_verification', False),
        )

    if not args.skip_score:
        ovo.score_results(out_path)


if __name__ == "__main__":
    main()