File size: 8,415 Bytes
510ab6b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Crash-safe writer for Predictor v4 Full-DiT teacher trajectories."""

from __future__ import annotations

import json
import os
from collections.abc import Mapping
from pathlib import Path
from typing import Any

import torch
from safetensors.torch import save_file

from .schema import (
    CANDIDATE_BLOCK_IDS,
    SCHEMA_VERSION,
    validate_case_tensors,
    validate_clean_prefeature,
    validate_step_tensors,
)


def _bf16_cpu(tensor: torch.Tensor) -> torch.Tensor:
    return tensor.detach().to(device="cpu", dtype=torch.bfloat16).contiguous()


def atomic_write_text(path: Path, text: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    temporary = path.with_name(f".{path.name}.tmp.{os.getpid()}")
    with temporary.open("w", encoding="utf-8") as handle:
        handle.write(text)
        handle.flush()
        os.fsync(handle.fileno())
    os.replace(temporary, path)


def atomic_save_safetensors(
    path: Path,
    tensors: Mapping[str, torch.Tensor],
) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    temporary = path.with_name(f".{path.name}.tmp.{os.getpid()}")
    try:
        save_file(dict(tensors), str(temporary))
        os.replace(temporary, path)
    finally:
        if temporary.exists():
            temporary.unlink()


class PredictorV4DatasetWriter:
    """Write one worker shard and fsync every committed manifest record."""

    def __init__(
        self,
        root: str | os.PathLike[str],
        *,
        worker_id: int,
        block_ids: tuple[int, ...] = CANDIDATE_BLOCK_IDS,
    ) -> None:
        self.root = Path(root).resolve()
        self.worker_id = int(worker_id)
        self.block_ids = tuple(int(value) for value in block_ids)
        self.case_dir = self.root / "cases"
        self.step_dir = self.root / "steps"
        self.clean_dir = self.root / "clean_prefeature"
        self.manifest_dir = self.root / "manifests"
        self.log_dir = self.root / "logs"
        self.manifest_path = self.manifest_dir / f"worker_{self.worker_id:02d}.jsonl"
        for path in (
            self.case_dir,
            self.step_dir,
            self.clean_dir,
            self.manifest_dir,
            self.log_dir,
        ):
            path.mkdir(parents=True, exist_ok=True)
        self._records = self._read_committed_records()

    def _read_committed_records(self) -> dict[tuple[int, int], dict[str, Any]]:
        records: dict[tuple[int, int], dict[str, Any]] = {}
        if not self.manifest_path.is_file():
            return records
        text = self.manifest_path.read_text(encoding="utf-8")
        lines = text.splitlines(keepends=True)
        committed_lines: list[str] = []
        for line_number, line in enumerate(lines, start=1):
            if not line.strip():
                committed_lines.append(line)
                continue
            try:
                item = json.loads(line)
            except json.JSONDecodeError as exc:
                is_truncated_tail = (
                    line_number == len(lines) and not line.endswith(("\n", "\r"))
                )
                if not is_truncated_tail:
                    raise ValueError(
                        f"invalid JSON at {self.manifest_path}:{line_number}"
                    ) from exc
                # A process may be killed between write(2) and fsync(2). Repair
                # only an unterminated final record; corruption elsewhere is
                # never silently ignored.
                atomic_write_text(self.manifest_path, "".join(committed_lines))
                break
            key = (int(item["case_id"]), int(item["chunk_id"]))
            if key in records and records[key] != item:
                raise ValueError(f"conflicting duplicate worker manifest record {key}")
            records[key] = item
            committed_lines.append(line)
        return records

    def case_path(self, case_id: int) -> Path:
        return self.case_dir / f"case_{int(case_id):06d}.safetensors"

    def step_path(self, case_id: int, chunk_id: int) -> Path:
        return (
            self.step_dir
            / f"case_{int(case_id):06d}"
            / f"chunk_{int(chunk_id):02d}.safetensors"
        )

    def clean_path(self, block_id: int, case_id: int, chunk_id: int) -> Path:
        return (
            self.clean_dir
            / f"block_{int(block_id):02d}"
            / f"case_{int(case_id):06d}"
            / f"chunk_{int(chunk_id):02d}.safetensors"
        )

    def _record_files_exist(self, record: Mapping[str, Any]) -> bool:
        paths = [
            self.root / str(record["case_tensor_file"]),
            self.root / str(record["step_tensor_file"]),
        ]
        paths.extend(
            self.root / str(value)
            for value in record["clean_prefeature_files"].values()
        )
        return all(path.is_file() for path in paths)

    def is_chunk_complete(self, case_id: int, chunk_id: int) -> bool:
        record = self._records.get((int(case_id), int(chunk_id)))
        return record is not None and self._record_files_exist(record)

    def save_case(
        self,
        case_id: int,
        tensors: Mapping[str, torch.Tensor],
    ) -> Path:
        converted = {name: _bf16_cpu(value) for name, value in tensors.items()}
        validate_case_tensors(converted, self.block_ids)
        path = self.case_path(case_id)
        if not path.is_file():
            atomic_save_safetensors(path, converted)
        return path

    def save_chunk(
        self,
        *,
        case_id: int,
        chunk_id: int,
        step_tensors: Mapping[str, torch.Tensor],
        clean_features: Mapping[int, torch.Tensor],
        start_frame: int,
        metadata: Mapping[str, Any],
    ) -> Path:
        key = (int(case_id), int(chunk_id))
        if self.is_chunk_complete(*key):
            return self.step_path(*key)
        if not self.case_path(case_id).is_file():
            raise RuntimeError(f"case tensor file must be saved before chunk {key}")

        converted_steps: dict[str, torch.Tensor] = {}
        for name, value in step_tensors.items():
            if name.endswith("_timestep"):
                converted_steps[name] = value.detach().to(
                    device="cpu", dtype=torch.int64
                ).contiguous()
            else:
                converted_steps[name] = _bf16_cpu(value)
        validate_step_tensors(converted_steps)

        converted_clean: dict[int, dict[str, torch.Tensor]] = {}
        for block_id in self.block_ids:
            if block_id not in clean_features:
                raise ValueError(f"missing clean prefeature for block {block_id}")
            values = {
                "self_attn_input": _bf16_cpu(clean_features[block_id]),
                "start_frame": torch.tensor([int(start_frame)], dtype=torch.int64),
                "num_frames": torch.tensor([3], dtype=torch.int64),
            }
            validate_clean_prefeature(block_id, values)
            converted_clean[block_id] = values

        step_path = self.step_path(*key)
        atomic_save_safetensors(step_path, converted_steps)
        clean_files: dict[str, str] = {}
        for block_id, values in converted_clean.items():
            path = self.clean_path(block_id, *key)
            atomic_save_safetensors(path, values)
            clean_files[str(block_id)] = str(path.relative_to(self.root))

        record = {
            "schema_version": SCHEMA_VERSION,
            "case_id": key[0],
            "chunk_id": key[1],
            "case_tensor_file": str(self.case_path(case_id).relative_to(self.root)),
            "step_tensor_file": str(step_path.relative_to(self.root)),
            "clean_prefeature_files": clean_files,
            "candidate_block_ids": list(self.block_ids),
            "context_frames": int(start_frame),
            **dict(metadata),
        }
        existing = self._records.get(key)
        if existing is None:
            line = json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n"
            with self.manifest_path.open("a", encoding="utf-8") as handle:
                handle.write(line)
                handle.flush()
                os.fsync(handle.fileno())
            self._records[key] = record
        elif existing != record:
            raise ValueError(f"recomputed metadata differs for manifest record {key}")
        return step_path