File size: 12,898 Bytes
cfbcb75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Host side of the P100a models: start the TT worker container on the card and stand in for the DiT and
the audio VAE decoder of the host pipeline.

The ACE-Step port is the sibling repo, tt-acestep: it shares this worker and transport.

Used from the DiffRhythm host pipeline: TTWorker(cfm_model.pt, model="diffrhythm", vae=vae_model.pt),
cfm.transformer = RemoteDiffRhythmDiT(worker, cfm.transformer), decoding with RemoteVae(worker, upsample=2048).

Runs in the model's own environment; needs only the standard library and torch.
"""
from __future__ import annotations

from multiprocessing.connection import Client
import os
from pathlib import Path
import secrets
import shutil
import subprocess
import tempfile
import time

import torch

from . import wire
from .shapes import VAE_WINDOWS

ROOT = Path(__file__).resolve().parents[1]
# The tt-metal image the worker runs in. There is no portable default: set MUSIC_TT_IMAGE,
# or pass image= , to the tt-metal / ttnn image you built the port against.
DEFAULT_IMAGE = ""


class WorkerError(RuntimeError):
    """The P100a worker failed (died, hung or reported an error)."""


class TTWorker:
    """checkpoint is cfm_model.pt and vae is vae_model.pt (Hugging Face cache links are resolved)."""

    def __init__(self, checkpoint: Path, image: str = None, log=None,
                 reply_timeout_s: float = 120.0,  # the slowest call (an 8-minute song's DiT step or VAE window) is < 1 s
                 model: str = "diffrhythm", vae: Path = None):
        self.checkpoint = Path(checkpoint)
        self.model = model
        self.vae = Path(vae) if vae is not None else None
        self.image = image or os.environ.get("MUSIC_TT_IMAGE") or DEFAULT_IMAGE
        if not self.image:
            raise ValueError("set MUSIC_TT_IMAGE (or pass image=) to the tt-metal image the worker runs in")
        self.log = log
        self.reply_timeout_s = reply_timeout_s
        self.proc = self.conn = self.ipc = None
        self.name = f"music-tt-{os.getpid()}-{secrets.token_hex(3)}"
        self.stats = {}
        self.failed = False  # set when the worker died, hung or reported an error: the card may be unhealthy

    # ------------------------------------------------------------------ lifecycle
    def start(self) -> "TTWorker":
        try:
            cache = ROOT / "data/tt-cache"
            cache.mkdir(parents=True, exist_ok=True)
            run_dir = ROOT / "data/tt-run"
            run_dir.mkdir(parents=True, exist_ok=True)
            self.ipc = Path(tempfile.mkdtemp(prefix="dit-", dir=run_dir))
            self.authkey = secrets.token_hex(16)
            mounts = [("/dev/hugepages-1G", "/dev/hugepages-1G", False), (ROOT, "/work", True),
                      *self._model_mounts(), (cache, "/cache", False), (self.ipc, "/ipc", False)]
            cmd = ["docker", "run", "--rm", "--name", self.name, "--ipc", "host", "--device", "/dev/tenstorrent"]
            for src, dst, ro in mounts:
                cmd += ["--mount", f"type=bind,src={src},dst={dst}" + (",readonly" if ro else "")]
            for k, v in {"TT_METAL_VISIBLE_DEVICES": "0", "MESH_DEVICE": "P100", "PYTHONUNBUFFERED": "1",
                         "TT_METAL_OPERATION_TIMEOUT_SECONDS": "90", "MUSIC_TT_AUTHKEY": self.authkey}.items():
                cmd += ["-e", f"{k}={v}"]
            cmd += [self.image, "python", "/work/tt_diffrhythm/worker.py", "--socket", "/ipc/worker.sock", "--model", self.model]
            # own session: a supervisor may stop the host process by signalling its process group, and a signal reaching
            # `docker run` directly would stop the container while this process still has to wait for it
            self.proc = subprocess.Popen(cmd, stdout=self.log or subprocess.DEVNULL, stderr=subprocess.STDOUT,
                                         start_new_session=True)
            self._started = time.monotonic()
        except BaseException:
            self.close()
            raise
        return self

    def _model_mounts(self):
        if self.model == "diffrhythm":  # files, mounted where worker.py expects them
            return [(self.checkpoint.resolve(), "/checkpoint/cfm_model.pt", True),
                    (self.vae.resolve(), "/vae/vae_model.pt", True)]
        raise ValueError(f"unknown model {self.model!r}")

    def died(self) -> bool:
        """The worker container exited on its own (not through close())."""
        return self.proc is not None and self.proc.poll() is not None

    def _fail(self, message):
        self.failed = True
        raise WorkerError(message)

    def wait_ready(self, timeout_s: float = 300.0):
        sock = self.ipc / "worker.sock"
        deadline = time.monotonic() + timeout_s
        while not sock.exists():
            self._check_alive()
            if time.monotonic() > deadline:
                self._fail("TT worker did not open its socket")
            time.sleep(0.2)
        try:
            self.conn = Client(str(sock), family="AF_UNIX", authkey=self.authkey.encode())
        except (OSError, EOFError) as exc:  # the worker died while loading
            self._fail(f"TT worker connection failed: {exc}")
        try:
            ready = self._recv(timeout_s)
        except (OSError, EOFError) as exc:
            self._fail(f"TT worker connection lost: {exc}")
        self.stats.update(worker_load_s=round(ready["load_s"], 2), start_to_ready_s=round(time.monotonic() - self._started, 2))
        return ready

    def close(self):
        """Stop the worker and wait for the container to exit; only then give the card back."""
        try:
            if self.conn is not None:
                try:
                    self.conn.send({"op": "close"})
                except OSError:
                    pass
                self.conn.close()
            if self.proc is not None:
                try:
                    self.proc.wait(60)
                except subprocess.TimeoutExpired:
                    subprocess.run(["docker", "stop", "-t", "20", self.name], capture_output=True, timeout=60)
                    self.proc.wait(60)
        finally:
            self.conn = None
            if self.ipc is not None:
                shutil.rmtree(self.ipc, ignore_errors=True)

    def __enter__(self):
        return self.start()

    def __exit__(self, *exc):
        self.close()

    # ------------------------------------------------------------------ calls
    def _check_alive(self):
        if self.proc is not None and self.proc.poll() is not None:
            self._fail(f"TT worker exited ({self.proc.returncode}); see the worker log")

    def _recv(self, timeout_s):
        deadline = time.monotonic() + timeout_s
        while not self.conn.poll(1.0):
            self._check_alive()
            if time.monotonic() > deadline:
                self._fail("TT worker did not answer in time")
        msg = self.conn.recv()
        if msg.get("op") == "error":
            self._fail(msg["error"])
        return msg

    def call(self, msg: dict) -> dict:
        try:
            self.conn.send(msg)
            return self._recv(self.reply_timeout_s)
        except (OSError, EOFError) as exc:  # the socket broke: the worker is gone
            self._fail(f"TT worker connection lost: {exc}")


class RemoteDiffRhythmDiT(torch.nn.Module):
    """Drop-in for DiffRhythm's DiT (cfm.transformer) in CFM.sample (batch 1): same call, same output.

    Keeps upstream's small modules on the host, exactly as upstream runs them: the text embedding (computed once per
    song and CFG branch; upstream recomputes the same thing every call) and the time / start / duration embeddings.
    Everything weight-heavy (input projection, position convs, 16 Llama layers, output) runs on the card."""

    def __init__(self, worker: TTWorker, dit):
        super().__init__()
        self.worker = worker
        self.text_embed, self.time_embed = dit.text_embed, dit.time_embed
        self.start_time_embed, self.duration_time_embed = dit.start_time_embed, dit.duration_time_embed
        self._song = None
        self.seconds = []

    def reset(self) -> None:
        """Forget which song is prepared on the card.

        The worker releases the song's tensors when the VAE runs, so that every song length meets
        the same device allocation state and the VAE's compiled kernels are reused. After that the
        card holds no song, and this cache -- which exists to skip re-sending an unchanged text and
        conditioning -- would otherwise send a `forward` for a song that is no longer there.

        A caller that builds a shim per song never notices. One that keeps it across songs, as a
        server does, has to call this between them.
        """
        self._song = None

    def forward(self, x, cond, text, time, drop_audio_cond, drop_text, drop_prompt=False, style_prompt=None,
                start_time=None, duration=None):
        if x.shape[0] != 1:
            raise ValueError("The P100a DiT runs batch 1")
        if bool(drop_audio_cond) != bool(drop_text):
            raise ValueError("The P100a DiT runs CFM.sample's two branches (both conditions kept or both dropped)")
        seq_len = x.shape[1]
        if self._song is None or not (torch.equal(text, self._song[0]) and torch.equal(cond, self._song[1])):
            embeds = {null: wire.encode(self.text_embed(text, seq_len, drop_text=null)[0]) for null in (False, True)}
            self.worker.call({"op": "prepare", "text_embed": embeds, "cond": wire.encode(cond[0])})
            self._song = (text.clone(), cond.clone())
        if time.ndim == 0:
            time = time.repeat(1)
        dtype = next(self.time_embed.parameters()).dtype  # upstream hands over fp16 style/start/duration tensors
        c = (self.time_embed(time.to(dtype)) + self.start_time_embed(start_time.to(dtype))
             + self.duration_time_embed(duration.to(dtype)))
        style = torch.zeros_like(style_prompt) if drop_prompt else style_prompt
        reply = self.worker.call({"op": "forward", "x": wire.encode(x[0]), "drop_text": bool(drop_text),
                                  "style": wire.encode(style[0]), "c": wire.encode(c[0])})
        self.seconds.append(reply["seconds"])
        return wire.decode(reply["v"]).to(x.dtype)[None]


class RemoteVae:
    """Stands in for handler.tiled_decode: overlap-discard tiling like upstream's, but every window of a song has the
    same length (the last one is moved back to end at the song's end), so the P100a compiles only a few shapes.

    The window is the largest of `chunks` that fits the song: 1024 frames (41 s) decode at 0.61 ms per frame with
    12.5 % overlap, 512 frames at the same rate with 25 % overlap (vae_profile.py); longer windows get slower per
    frame. Songs of 30-41 s use 512, so no song compiles a shape of its own.

    latents [B, 64, T] -> audio [B, 2, T * upsample] float32 on the CPU (1920 for ACE-Step, 2048 for DiffRhythm)."""

    def __init__(self, worker: TTWorker, chunks=VAE_WINDOWS, overlap: int = 64, upsample: int = 1920):
        self.worker, self.chunks, self.overlap, self.upsample = worker, tuple(sorted(chunks, reverse=True)), overlap, upsample
        self.seconds = []

    def chunk_for(self, frames: int) -> int:
        return next((c for c in self.chunks if c <= frames), self.chunks[-1])

    @staticmethod
    def windows(frames: int, chunk: int, overlap: int):
        """(window_start, core_start, core_end) covering [0, frames); windows are exactly `chunk` frames long
        (or the whole song when it is shorter) and keep >= overlap frames of context around each core."""
        if frames <= chunk:
            return [(0, 0, frames)]
        stride = chunk - 2 * overlap
        out = []
        for core_start in range(0, frames, stride):
            core_end = min(core_start + stride, frames)
            out.append((min(max(core_start - overlap, 0), frames - chunk), core_start, core_end))
        return out

    def _decode(self, z: torch.Tensor) -> torch.Tensor:
        reply = self.worker.call({"op": "vae", "z": wire.encode(z)})
        self.seconds.append(reply["seconds"])
        return wire.decode(reply["audio"])

    def __call__(self, latents, chunk_size=None, overlap=None, offload_wav_to_cpu=None):
        out = []
        for z in latents.detach().float().cpu():
            frames = z.shape[-1]
            chunk = self.chunk_for(frames)
            pieces = []
            for win_start, core_start, core_end in self.windows(frames, chunk, self.overlap):
                win_end = min(win_start + chunk, frames)
                audio = self._decode(z[:, win_start:win_end].contiguous())
                pieces.append(audio[:, (core_start - win_start) * self.upsample:(core_end - win_start) * self.upsample])
            out.append(torch.cat(pieces, dim=-1))
        return torch.stack(out)