Buckets:
| """Tests for fpgm.datagen.batch.runner.BatchRunner. | |
| ``fpgm.datagen.pipeline`` does not exist yet (a concurrent change lands it); | |
| per this task's own brief, production code | |
| (``fpgm/datagen/batch/runner.py``) imports it unconditionally and | |
| unconditionally-deferred (see that module's docstring for why). These tests | |
| develop against a stub injected into ``sys.modules["fpgm.datagen.pipeline"]`` | |
| -- never an import-guard in the production module itself. | |
| Every test here uses an in-thread ``ThreadPoolExecutor`` (via | |
| ``BatchRunner``'s own ``executor_factory`` hook) instead of a real | |
| ``ProcessPoolExecutor``, specifically *because* threads share this process's | |
| ``sys.modules`` with the stub, while a real spawned child process would not | |
| see it. The real multi-process/env-var-ordering path is exercised directly | |
| against ``_run_shard`` and separately by the task's own manual GPU/CUDA | |
| verification -- this file is about ``BatchRunner``'s orchestration logic | |
| (sharding, pilot policy, resume, timeout-as-failure, pruning), which is | |
| identical either way. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import sys | |
| import time | |
| import types | |
| from concurrent.futures import ThreadPoolExecutor | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| import h5py | |
| import pytest | |
| from fpgm.config_datagen import DatagenProfile | |
| from fpgm.datagen.batch.gpu_pool import GpuWorkerPool | |
| from fpgm.datagen.batch.runner import BatchRunner | |
| REPO_ROOT = Path(__file__).resolve().parents[1] | |
| REAL_MESH = REPO_ROOT / "assets" / "scenes" / "8756300955" / "manipulated.glb" | |
| RESOLVABLE_TASK = "Put brick in drawer shelf and close drawer" | |
| UNPARSEABLE_TASK = "Do anything you like that takes multiple steps to complete." | |
| # --------------------------------------------------------------------------- # | |
| # Stub fpgm.datagen.pipeline -- see module docstring for why this exists | |
| # --------------------------------------------------------------------------- # | |
| def _install_pipeline_stub(behavior: dict[str, str]) -> types.ModuleType: | |
| """``behavior``: {uuid: "ok" | "fail" | "hang"}, default "ok" for unlisted uuids.""" | |
| class StageResult: | |
| name: str | |
| status: str | |
| payload: dict | |
| elapsed_s: float | |
| error: str | None = None | |
| class EpisodeReport: | |
| spec: object | |
| stages: tuple | |
| gates: dict | |
| windows: tuple | |
| def status(self) -> str: | |
| return "ok" if all(s.status == "ok" for s in self.stages) else "failed" | |
| def to_json(self) -> dict: | |
| return { | |
| "stages": [ | |
| {"name": s.name, "status": s.status, "payload": s.payload, | |
| "elapsed_s": s.elapsed_s, "error": s.error} | |
| for s in self.stages | |
| ], | |
| "gates": self.gates, | |
| "windows": list(self.windows), | |
| } | |
| call_log: list[str] = [] | |
| class EpisodePipeline: | |
| STAGES = ("s2", "s3", "s4", "s6", "s8") | |
| def __init__(self, profile, registry=None) -> None: | |
| self.profile = profile | |
| self.registry = registry | |
| def run(self, spec, stages=None, *, force: bool = False) -> EpisodeReport: | |
| call_log.append(spec.uuid) | |
| # One reusable model per registry, regardless of how many episodes | |
| # this registry processes -- exactly the reuse behavior the real | |
| # pipeline is meant to exhibit (see ModelRegistry's own docstring). | |
| if self.registry is not None: | |
| self.registry._get_or_build(("stub_model",), lambda: object()) | |
| mode = behavior.get(spec.uuid, "ok") | |
| if mode == "hang": | |
| time.sleep(30) | |
| mode = "ok" | |
| if mode == "fail": | |
| failed_stage = StageResult("s2", "failed", {}, 0.01, error="stub failure") | |
| return EpisodeReport(spec=spec, stages=(failed_stage,), gates={}, windows=()) | |
| window = {"window": "w0", "overlaps_pose_gap": False, "high_hole_fraction": False} | |
| return EpisodeReport( | |
| spec=spec, | |
| stages=(StageResult("s2", "ok", {"n": 1}, 0.01),), | |
| gates={"dummy_gate": {"pass": True, "value": 1.0}}, | |
| windows=(window,), | |
| ) | |
| module = types.ModuleType("fpgm.datagen.pipeline") | |
| module.EpisodePipeline = EpisodePipeline | |
| module.EpisodeReport = EpisodeReport | |
| module.StageResult = StageResult | |
| module.call_log = call_log # test-only escape hatch | |
| return module | |
| def pipeline_stub(monkeypatch): | |
| installed: dict[str, types.ModuleType] = {} | |
| def _install(behavior: dict[str, str] | None = None): | |
| module = _install_pipeline_stub(behavior or {}) | |
| monkeypatch.setitem(sys.modules, "fpgm.datagen.pipeline", module) | |
| installed["module"] = module | |
| return module | |
| yield _install | |
| installed.clear() | |
| # --------------------------------------------------------------------------- # | |
| # Profile / episode fixtures | |
| # --------------------------------------------------------------------------- # | |
| def _write_flows_h5(path: Path, uuid: str, task: str, camera_serial: str = "cam1") -> None: | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| with h5py.File(path, "w") as fh: | |
| fh.attrs["uuid"] = uuid | |
| fh.attrs["scene_id"] = "8756300955" | |
| fh.attrs["current_task"] = task | |
| fh.attrs["ext1_cam_serial"] = camera_serial | |
| def _write_profile_yaml(tmp_path: Path, **batch_overrides) -> Path: | |
| batch = { | |
| "camera_role": "ext1", | |
| "pilot_fraction": 0.5, | |
| "pilot_min_episodes": 1, | |
| "pilot_pass_threshold": 0.6, | |
| "episode_timeout_s": 2.0, | |
| "high_hole_fraction_gate": 0.10, | |
| "prune_intermediates": True, | |
| } | |
| batch.update(batch_overrides) | |
| import yaml | |
| raw = { | |
| "paths": { | |
| "droid_raw_root": str(tmp_path / "droid_raw"), | |
| "flows_root": str(tmp_path / "flows"), | |
| "cameras_root": str(tmp_path / "cameras"), | |
| "output_root": str(tmp_path / "outputs"), | |
| "checkpoints_root": str(tmp_path / "checkpoints"), | |
| "urdf": str(tmp_path / "fake.urdf"), | |
| "python_executable": sys.executable, | |
| }, | |
| "scene_assets": {"meshes": {"8756300955": {"manipulated": str(REAL_MESH)}}}, | |
| "gpu": { | |
| "device_ids": None, "min_free_mb": 0, "workers_per_device": 1, | |
| "max_workers": None, "threads_per_worker": 2, | |
| "acquire_timeout_s": 5.0, "poll_interval_s": 0.05, | |
| }, | |
| "batch": batch, | |
| "publish": {"bucket_url": "hf://buckets/test/test"}, | |
| } | |
| yaml_path = tmp_path / "profile.yaml" | |
| yaml_path.write_text(yaml.safe_dump(raw)) | |
| return yaml_path | |
| def _make_episodes( | |
| tmp_path: Path, n_ok: int, *, n_unparseable: int = 0 | |
| ) -> tuple[DatagenProfile, Path, list[str]]: | |
| yaml_path = _write_profile_yaml(tmp_path) | |
| profile = DatagenProfile.from_yaml(yaml_path) | |
| uuids = [] | |
| for i in range(n_ok): | |
| uuid = f"AUTOLab+ok{i:03d}+2024-01-01-00h-00m-00s" | |
| (profile.paths.droid_raw_root / uuid).mkdir(parents=True, exist_ok=True) | |
| _write_flows_h5(profile.paths.flows_h5(uuid), uuid, RESOLVABLE_TASK) | |
| uuids.append(uuid) | |
| for i in range(n_unparseable): | |
| uuid = f"AUTOLab+skip{i:03d}+2024-01-01-00h-00m-00s" | |
| (profile.paths.droid_raw_root / uuid).mkdir(parents=True, exist_ok=True) | |
| _write_flows_h5(profile.paths.flows_h5(uuid), uuid, UNPARSEABLE_TASK) | |
| uuids.append(uuid) | |
| return profile, yaml_path, uuids | |
| def _thread_executor_factory(n: int) -> ThreadPoolExecutor: | |
| return ThreadPoolExecutor(max_workers=n) | |
| def _make_runner(profile: DatagenProfile, yaml_path: Path, *, n_devices: int = 2) -> BatchRunner: | |
| from fpgm.config_datagen import GpuPoolConfig | |
| measured = {i: 50000 for i in range(n_devices)} | |
| gpu_pool = GpuWorkerPool( | |
| GpuPoolConfig( | |
| min_free_mb=0, workers_per_device=1, acquire_timeout_s=5.0, poll_interval_s=0.02 | |
| ), | |
| query_fn=lambda: measured, | |
| ) | |
| return BatchRunner( | |
| profile, yaml_path, gpu_pool=gpu_pool, executor_factory=_thread_executor_factory | |
| ) | |
| # --------------------------------------------------------------------------- # | |
| # Tests | |
| # --------------------------------------------------------------------------- # | |
| class TestDiscoveryAndJobs: | |
| def test_discover_episode_uuids(self, tmp_path, pipeline_stub): | |
| pipeline_stub() | |
| profile, yaml_path, uuids = _make_episodes(tmp_path, n_ok=3) | |
| runner = _make_runner(profile, yaml_path) | |
| assert runner.discover_episode_uuids() == sorted(uuids) | |
| def test_build_jobs_defaults_camera_role_from_profile(self, tmp_path, pipeline_stub): | |
| pipeline_stub() | |
| profile, yaml_path, uuids = _make_episodes(tmp_path, n_ok=2) | |
| runner = _make_runner(profile, yaml_path) | |
| jobs = runner.build_jobs() | |
| assert all(j.camera_role == "ext1" for j in jobs) | |
| class TestFullRun: | |
| def test_all_episodes_recorded_ok_and_sharded_with_per_episode_registry_release( | |
| self, tmp_path, pipeline_stub | |
| ): | |
| module = pipeline_stub() | |
| profile, yaml_path, uuids = _make_episodes(tmp_path, n_ok=6) | |
| # workers_per_device=1 -> concurrency=2 with 2 devices | |
| runner = _make_runner(profile, yaml_path, n_devices=2) | |
| summary = runner.run(mode="full") | |
| assert {e["uuid"] for e in summary.episodes} == set(uuids) | |
| assert all(e["status"] == "ok" for e in summary.episodes) | |
| assert sorted(module.call_log) == sorted(uuids) | |
| # This assertion was inverted deliberately, and the inversion is the | |
| # point of the test. | |
| # | |
| # It used to require ONE model construction per shard -- cross-episode | |
| # registry reuse. That contract was given up on measured evidence: in | |
| # a real worker process, episode 1 ok, episode 2 ok, episode 3 died at | |
| # "30.51 GiB in use ... 29.55 GiB allocated by PyTorch". Almost all of | |
| # it *allocated* rather than *reserved*, i.e. live references retained | |
| # across episodes, which no allocator flag or empty_cache() can | |
| # reclaim. ``_run_shard`` now releases the registry after every | |
| # episode, so constructions scale with episodes, not with shards. | |
| # | |
| # The count is asserted to *rise* rather than merely "not be 1", so | |
| # that silently restoring reuse (and the leak with it) fails here | |
| # rather than passing a weaker check. | |
| shard_ids = {e["shard_id"] for e in summary.episodes} | |
| assert len(shard_ids) == 2 | |
| for shard_id in shard_ids: | |
| counts = sorted( | |
| e["model_construction_count"] | |
| for e in summary.episodes if e["shard_id"] == shard_id | |
| ) | |
| assert counts == list(range(1, len(counts) + 1)), ( | |
| "each episode must rebuild after the previous one's release, so a shard's " | |
| f"construction counts should be 1,2,3,... -- got {counts}" | |
| ) | |
| # checkpoint actually written to disk | |
| assert summary.json_path.exists() | |
| on_disk = json.loads(summary.json_path.read_text()) | |
| assert on_disk["n_ok"] == 6 | |
| def test_failed_episode_status_and_gate_extraction(self, tmp_path, pipeline_stub): | |
| profile, yaml_path, uuids = _make_episodes(tmp_path, n_ok=2) | |
| pipeline_stub({uuids[0]: "fail"}) | |
| runner = _make_runner(profile, yaml_path, n_devices=1) | |
| summary = runner.run(mode="full") | |
| by_uuid = {e["uuid"]: e for e in summary.episodes} | |
| assert by_uuid[uuids[0]]["status"] == "failed" | |
| assert by_uuid[uuids[1]]["status"] == "ok" | |
| class TestSkipping: | |
| def test_unparseable_instruction_falls_back_to_motion_not_skipped( | |
| self, tmp_path, pipeline_stub | |
| ): | |
| """No episode is refused for an unparseable DROID instruction any more -- | |
| see fpgm.datagen.episode_spec's module docstring. An episode whose | |
| instruction fails to parse now runs the (stubbed) pipeline with | |
| objects_from="motion" instead of being recorded "skipped" and never | |
| reaching the pipeline at all -- the inverse of this test's own old name. | |
| """ | |
| module = pipeline_stub() | |
| profile, yaml_path, uuids = _make_episodes(tmp_path, n_ok=2, n_unparseable=2) | |
| runner = _make_runner(profile, yaml_path, n_devices=2) | |
| summary = runner.run(mode="full") | |
| by_status = {} | |
| for e in summary.episodes: | |
| by_status.setdefault(e["status"], []).append(e["uuid"]) | |
| assert not by_status.get("skipped") | |
| assert len(by_status.get("ok", [])) == 4 | |
| objects_from_by_uuid = {e["uuid"]: e.get("objects_from") for e in summary.episodes} | |
| unparseable_uuids = [u for u in uuids if "skip" in u] | |
| parseable_uuids = [u for u in uuids if "skip" not in u] | |
| assert unparseable_uuids and parseable_uuids # sanity: fixture built both kinds | |
| assert all(objects_from_by_uuid[u] == "motion" for u in unparseable_uuids) | |
| assert all(objects_from_by_uuid[u] == "text" for u in parseable_uuids) | |
| # The pipeline IS invoked for every episode now, unparseable ones included. | |
| assert set(module.call_log) == set(uuids) | |
| class TestTimeout: | |
| def test_hung_episode_ends_its_shard_and_defers_the_rest(self, tmp_path, pipeline_stub): | |
| """A timeout must end the shard, not merely skip one episode. | |
| This assertion was inverted deliberately. It used to require the shard | |
| to carry on to the next episode, which is what the code did -- and that | |
| behaviour was measured to be actively harmful: ``_run_with_timeout`` | |
| abandons its worker thread (there is no portable way to kill a thread | |
| stuck in native CUDA code), so the thread keeps ~20 GB of GPU memory | |
| alive. On the real pilot, one timeout took down the three episodes | |
| after it: the next reported "reserved 31.78 GB" before doing any work, | |
| died inside SAM3 with a bare ``KeyError('previous_stages_out')``, and | |
| the one after that OOM'd in 26 s. Four episodes lost, looking like | |
| three unrelated bugs. | |
| Letting the worker *process* exit is the only reliable way to reclaim | |
| an abandoned thread's memory. The deferred episodes are not recorded as | |
| failures -- they were never attempted -- and the multi-pass campaign | |
| re-runs them in a clean process. | |
| """ | |
| profile, yaml_path, uuids = _make_episodes(tmp_path, n_ok=2) | |
| pipeline_stub({uuids[0]: "hang"}) | |
| yaml_path = _write_profile_yaml(tmp_path, episode_timeout_s=0.3) | |
| profile = DatagenProfile.from_yaml(yaml_path) | |
| runner = _make_runner(profile, yaml_path, n_devices=1) | |
| t0 = time.time() | |
| summary = runner.run(mode="full") | |
| elapsed = time.time() - t0 | |
| by_uuid = {e["uuid"]: e for e in summary.episodes} | |
| assert by_uuid[uuids[0]]["status"] == "failed" | |
| error_msg = by_uuid[uuids[0]]["error"].lower() | |
| assert "timeout" in error_msg or "exceeded" in error_msg | |
| # The episode behind the hung one is DEFERRED, not run in the poisoned | |
| # process. Either it is absent from the summary entirely, or the | |
| # crashed-shard path recorded it as failed -- what must never happen is | |
| # it being reported "ok" from a process holding a zombie allocation. | |
| assert by_uuid.get(uuids[1], {}).get("status") != "ok", ( | |
| "an episode queued behind a timeout must not be executed in the " | |
| "same, memory-poisoned worker process" | |
| ) | |
| assert elapsed < 10, "the hung episode's 30s sleep must not have stalled the batch" | |
| class TestResume: | |
| def test_already_ok_episode_is_not_reprocessed(self, tmp_path, pipeline_stub): | |
| module = pipeline_stub() | |
| profile, yaml_path, uuids = _make_episodes(tmp_path, n_ok=2) | |
| runner = _make_runner(profile, yaml_path, n_devices=1) | |
| runner.run(mode="full") | |
| assert sorted(module.call_log) == sorted(uuids) | |
| module.call_log.clear() | |
| runner2 = _make_runner(profile, yaml_path, n_devices=1) | |
| # reuse the same summary file on disk (BatchSummary.load_or_new) | |
| summary2 = runner2.run(mode="full") | |
| assert module.call_log == [] # nothing reprocessed | |
| assert {e["uuid"] for e in summary2.episodes} == set(uuids) | |
| class TestPilotPolicy: | |
| def test_pilot_mode_stops_without_running_remainder(self, tmp_path, pipeline_stub): | |
| module = pipeline_stub() | |
| profile, yaml_path, uuids = _make_episodes(tmp_path, n_ok=4) | |
| yaml_path = _write_profile_yaml(tmp_path, pilot_fraction=0.5, pilot_min_episodes=1) | |
| profile = DatagenProfile.from_yaml(yaml_path) | |
| runner = _make_runner(profile, yaml_path, n_devices=1) | |
| summary = runner.run(mode="pilot") | |
| assert len(module.call_log) == 2 # ceil(0.5*4) == 2 | |
| assert len(summary.episodes) == 2 | |
| assert summary.meta["pilot_decision"]["pilot_verdict"].startswith("stopped") | |
| def test_auto_mode_continues_when_pilot_passes(self, tmp_path, pipeline_stub): | |
| module = pipeline_stub() | |
| profile, yaml_path, uuids = _make_episodes(tmp_path, n_ok=4) | |
| yaml_path = _write_profile_yaml(tmp_path, pilot_fraction=0.5, pilot_min_episodes=1, | |
| pilot_pass_threshold=0.5) | |
| profile = DatagenProfile.from_yaml(yaml_path) | |
| runner = _make_runner(profile, yaml_path, n_devices=1) | |
| summary = runner.run(mode="auto") | |
| assert len(module.call_log) == 4 # pilot (2) + remainder (2) | |
| assert all(e["status"] == "ok" for e in summary.episodes) | |
| assert summary.meta["pilot_decision"]["pilot_verdict"].startswith("passed") | |
| def test_auto_mode_stops_when_pilot_fails_threshold(self, tmp_path, pipeline_stub): | |
| profile, yaml_path, uuids = _make_episodes(tmp_path, n_ok=4) | |
| # first 2 (the pilot, at pilot_fraction=0.5) fail -> 0% pass rate | |
| module = pipeline_stub({uuids[0]: "fail", uuids[1]: "fail"}) | |
| yaml_path = _write_profile_yaml(tmp_path, pilot_fraction=0.5, pilot_min_episodes=1, | |
| pilot_pass_threshold=0.6) | |
| profile = DatagenProfile.from_yaml(yaml_path) | |
| runner = _make_runner(profile, yaml_path, n_devices=1) | |
| summary = runner.run(mode="auto") | |
| assert len(module.call_log) == 2 # remainder never ran | |
| assert summary.meta["pilot_decision"]["pilot_verdict"].startswith("FAILED") | |
| assert summary.meta["pilot_decision"]["pilot_pass_rate"] == 0.0 | |
| class TestPruneIntermediates: | |
| def test_deletes_large_buffers_only_after_ok_and_reports_bytes(self, tmp_path, pipeline_stub): | |
| pipeline_stub() | |
| profile, yaml_path, uuids = _make_episodes(tmp_path, n_ok=1) | |
| runner = _make_runner(profile, yaml_path, n_devices=1) | |
| master_dir = profile.paths.master_dir(uuids[0], "cam1") | |
| buf_dir = master_dir / "robot_buffers" | |
| buf_dir.mkdir(parents=True) | |
| (buf_dir / "depth_mm.npy").write_bytes(b"x" * 1000) | |
| (buf_dir / "seg.npy").write_bytes(b"y" * 500) | |
| (buf_dir / "meta.json").write_text("{}") # must survive pruning | |
| summary = runner.run(mode="full") | |
| entry = summary.episodes[0] | |
| assert entry["status"] == "ok" | |
| assert entry["bytes_reclaimed"] == 1500 | |
| assert not (buf_dir / "depth_mm.npy").exists() | |
| assert not (buf_dir / "seg.npy").exists() | |
| assert (buf_dir / "meta.json").exists() | |
Xet Storage Details
- Size:
- 19.7 kB
- Xet hash:
- c1c0f5d54bd7a2121e4f4ea4cec1e3fcb8f27d6913bf779823d9d72c09feb222
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.