<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://tsingletarytt.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://tsingletarytt.github.io/" rel="alternate" type="text/html" /><updated>2026-08-19T09:01:53-07:00</updated><id>https://tsingletarytt.github.io/feed.xml</id><title type="html">Taylor Singletary</title><subtitle>Developer Relations at Tenstorrent</subtitle><author><name>Taylor Singletary</name></author><entry><title type="html">AnimateDiff on Tenstorrent Hardware: The Full Story</title><link href="https://tsingletarytt.github.io/writing/2026/06/23/animatediff-on-tt-hardware-the-full-story/" rel="alternate" type="text/html" title="AnimateDiff on Tenstorrent Hardware: The Full Story" /><published>2026-06-23T00:00:00-07:00</published><updated>2026-06-23T00:00:00-07:00</updated><id>https://tsingletarytt.github.io/writing/2026/06/23/animatediff-on-tt-hardware-the-full-story</id><content type="html" xml:base="https://tsingletarytt.github.io/writing/2026/06/23/animatediff-on-tt-hardware-the-full-story/"><![CDATA[<p>This started as a tutorial. It turned into something longer — a genuine engineering journey with a wrong turn that ran for weeks, a distillation attempt that failed in an interesting way, and a set of TT-hardware-specific patterns that only emerged under pressure from the silicon. The tutorial parts are still here. So is everything else.</p>

<hr />

<div style="display:flex;gap:12px;flex-wrap:wrap;margin:2rem 0">
  <img src="/assets/animatediff/demo_world_of_tomorrow.gif" alt="World of Tomorrow" style="max-width:200px;border-radius:4px" />
  <img src="/assets/animatediff/demo_phosphor_horizon.gif" alt="Phosphor Horizon" style="max-width:200px;border-radius:4px" />
  <img src="/assets/animatediff/p4-chip-city.gif" alt="Chip City" style="max-width:200px;border-radius:4px" />
</div>

<p><em>Generated on Blackhole P300C. 8 frames, 512×512, 25 steps PNDM. These exist because of everything that follows.</em></p>

<hr />

<h2 id="how-animatediff-models-work">How AnimateDiff models work</h2>

<p>AnimateDiff makes images move by adding temporal attention to Stable Diffusion. The standard SD UNet processes spatial positions within a single image. AnimateDiff inserts <code class="language-plaintext highlighter-rouge">TemporalTransformer</code> blocks — <code class="language-plaintext highlighter-rouge">AnimateDiffTransformer3D</code> in the diffusers implementation — at multiple points in the UNet. These blocks reshape the hidden states from <code class="language-plaintext highlighter-rouge">(batch×frames, channels, H, W)</code> to <code class="language-plaintext highlighter-rouge">(batch, frames, channels, H, W)</code> and run self-attention across the frame dimension instead of the spatial one. The result: each spatial position agrees with its counterpart in adjacent frames before the final image is committed.</p>

<p>The motion weights live in <code class="language-plaintext highlighter-rouge">motion_module.py</code>. They’re trained separately from the image model and loaded on top of any SD 1.x UNet. The checkpoint is <code class="language-plaintext highlighter-rouge">guoyww/animatediff-motion-adapter-v1-5-2</code>.</p>

<p>That’s the scope. Everything else is about making it work on the hardware.</p>

<hr />

<h2 id="animatediff-on-other-hardware-the-landscape">AnimateDiff on other hardware: the landscape</h2>

<p>AnimateDiff has a healthy ecosystem on conventional GPU hardware, primarily through <a href="https://github.com/comfyanonymous/ComfyUI">ComfyUI</a> and <a href="https://github.com/continue-revolution/sd-webui-animatediff">sd-webui-animatediff</a>. Understanding that landscape gives the Blackhole numbers context.</p>

<p><strong>RTX 4090 (24 GB GDDR6X)</strong> is the current consumer reference point. Community benchmarks for 8–16 frames at 512×512 with SD 1.5 + MotionAdapter at 25 steps report 30 seconds to roughly 2 minutes end-to-end, depending on scheduler, xformers/torch.compile usage, and whether the VAE is fp16 or full precision. At ~75 it/s for a single SD 1.5 image, the spatial denoising alone per frame takes under a second; the overhead is motion module injection, VAE decode, and pipeline orchestration.</p>

<p><strong>RTX 3090 (24 GB GDDR6X)</strong> runs roughly 25–35% slower than the 4090 at the same resolution and step count. For the same 8-frame, 512×512, 25-step workload, expect 45 seconds to 3 minutes. The 3090 remains viable because AnimateDiff’s SD 1.5 base fits comfortably in 24 GB even with the MotionAdapter loaded.</p>

<p><strong>A100 (40/80 GB HBM2e)</strong> is the cloud datacenter reference. HBM memory bandwidth (~2 TB/s on the 80 GB variant) versus GDDR6X (~1 TB/s on the 4090) gives it a meaningful bandwidth advantage for attention-heavy workloads. SD 1.5 + AnimateDiff at 512×512 runs in roughly 15–30 seconds per 8-frame clip on an A100 80 GB. The 4090 comes close in practice because AnimateDiff’s small feature dimensions mean it’s less bandwidth-constrained than larger models.</p>

<p><strong>H100 (80 GB HBM3)</strong> — the current datacenter ceiling for this class of model — brings peak HBM bandwidth to ~3.35 TB/s. For SD 1.5-scale AnimateDiff, the practical speedup over an A100 is modest because the model is small enough that you hit compute saturation before bandwidth limits at 512×512. The H100’s advantages show most clearly in higher-resolution or larger-batch workloads.</p>

<p><strong>Cloud inference APIs</strong> (Replicate, RunPod, Modal) serving AnimateDiff on A100 or H100 instances typically quote 15–45 seconds for an 8-frame, 512×512 clip at 20–25 steps, depending on cold-start and queue time. These numbers mix hardware generation and framework overhead making direct comparison difficult.</p>

<p><strong>Blackhole P300C — this implementation:</strong></p>

<table>
  <thead>
    <tr>
      <th>Mode</th>
      <th>Config</th>
      <th>s/frame</th>
      <th>Notes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Phase 2.5 PNDM</td>
      <td>1 chip, 8fr</td>
      <td>~12.5</td>
      <td>cross-frame blend</td>
    </tr>
    <tr>
      <td>Phase 3 skip up1+up2</td>
      <td>1 chip, 8fr</td>
      <td>~7.7</td>
      <td>real MotionAdapter, 5 injection pts</td>
    </tr>
    <tr>
      <td>Phase 3 full</td>
      <td>1 chip, 8fr</td>
      <td>~52</td>
      <td>all 7 injection pts</td>
    </tr>
    <tr>
      <td>Phase 2.5 PNDM</td>
      <td>4 chip (QB2), 16fr</td>
      <td><strong>~5.4</strong></td>
      <td>in-process sharding, 2.3× per-frame gain</td>
    </tr>
    <tr>
      <td>4-clip parallel</td>
      <td>4 chip (QB2), 4×8fr</td>
      <td>~26s/clip</td>
      <td>one process per chip, ~105s/batch</td>
    </tr>
  </tbody>
</table>

<p>The Phase 3 skip-path result (~62s for 8 frames with real MotionAdapter temporal attention) sits in the same range as A100 cloud inference. The 4-chip 16-frame sharded path at ~5.4 s/frame is faster than most community RTX 4090 benchmarks for the same frame count. Phase 2.5 at 8 frames (~100s) is slower than a tuned 4090 pipeline, though the comparison is apple-to-oranges: the TTNN pipeline runs unoptimized SD 1.4 without xformers or CUDA kernel fusion.</p>

<p>The more relevant frame is not raw speed — it’s full AnimateDiff quality on non-NVIDIA silicon, running against unmodified motion adapter weights, without patching the accelerator runtime.</p>

<hr />

<h2 id="the-wrong-turn">The wrong turn</h2>

<p>The original implementation applied <code class="language-plaintext highlighter-rouge">mm_sd_v15_v2.ckpt</code> motion weights to SD 3.5’s DiT transformer.</p>

<p>SD 3.5’s DiT operates on 2432-dimensional features. The AnimateDiff motion weights were trained for SD 1.5’s UNet, which operates on 320-dimensional features. The dimensions don’t match. No temporal attention was applied — the transformer accepted the weight tensors, the shapes didn’t align in the intended way, and the generation appeared to work because shared noise initialization across frames already produces some visual consistency. The “temporal coherence” was a mirage.</p>

<p>This ran for weeks without triggering an obvious error. The outputs looked reasonable. The bug was only caught by switching to a matching architecture and comparing the results directly.</p>

<p>The fix: use SD 1.4 UNet + MotionAdapter throughout. SD 1.4’s UNet operates on 320-dim features — the same as the motion adapter’s expectation. Once the architecture matched, the difference between “temporal coherence from shared noise” and “temporal coherence from trained attention” became visible immediately.</p>

<hr />

<h2 id="phase-1-cpu-baseline">Phase 1: CPU baseline</h2>

<p>With the architecture correct, Phase 1 is the simplest possible thing: wrap <code class="language-plaintext highlighter-rouge">diffusers.AnimateDiffPipeline</code> with the MotionAdapter checkpoint and run it on CPU.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">diffusers</span> <span class="kn">import</span> <span class="n">AnimateDiffPipeline</span><span class="p">,</span> <span class="n">MotionAdapter</span><span class="p">,</span> <span class="n">DDIMScheduler</span>
<span class="n">adapter</span> <span class="o">=</span> <span class="n">MotionAdapter</span><span class="p">.</span><span class="n">from_pretrained</span><span class="p">(</span><span class="s">"guoyww/animatediff-motion-adapter-v1-5-2"</span><span class="p">)</span>
<span class="n">pipe</span> <span class="o">=</span> <span class="n">AnimateDiffPipeline</span><span class="p">.</span><span class="n">from_pretrained</span><span class="p">(</span><span class="s">"CompVis/stable-diffusion-v1-4"</span><span class="p">,</span> <span class="n">motion_adapter</span><span class="o">=</span><span class="n">adapter</span><span class="p">)</span>
<span class="n">frames</span> <span class="o">=</span> <span class="n">pipe</span><span class="p">(</span><span class="n">prompt</span><span class="p">,</span> <span class="n">num_frames</span><span class="o">=</span><span class="mi">16</span><span class="p">,</span> <span class="n">num_inference_steps</span><span class="o">=</span><span class="mi">25</span><span class="p">).</span><span class="n">frames</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
</code></pre></div></div>

<p>Speed: roughly two minutes per frame on CPU. Temporal quality: real AnimateDiff — full MotionAdapter attention running inside the UNet blocks at 320-dim feature level. This is the reference point everything else is measured against.</p>

<p>The CPU path remains in the repo. It’s the easiest way to validate that a given prompt and set of weights produce coherent output before moving to hardware.</p>

<hr />

<h2 id="phase-2-ttnn-unet-on-blackhole">Phase 2: TTNN UNet on Blackhole</h2>

<p>The TTNN UNet for SD 1.4 lives in <code class="language-plaintext highlighter-rouge">tt-metal</code>. It’s already compiled and tested for Blackhole via <code class="language-plaintext highlighter-rouge">TT_METAL_ARCH_NAME=blackhole</code>. Phase 2 loads it instead of the CPU UNet and runs frame generation on the Blackhole P300C.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">device</span> <span class="o">=</span> <span class="n">setup_blackhole</span><span class="p">()</span>   <span class="c1"># open_mesh_device across all available chips
</span><span class="n">ttnn_model</span><span class="p">,</span> <span class="n">ttnn_vae</span> <span class="o">=</span> <span class="n">load_sd14_ttnn</span><span class="p">(</span><span class="n">device</span><span class="p">)</span>
<span class="c1"># denoise each frame: N sequential TTNN UNet calls per denoising step
</span><span class="n">frames</span> <span class="o">=</span> <span class="n">generate_frames_temporal</span><span class="p">(</span><span class="n">device</span><span class="p">,</span> <span class="n">ttnn_model</span><span class="p">,</span> <span class="n">ttnn_vae</span><span class="p">,</span> <span class="p">...)</span>
</code></pre></div></div>

<p>Speed on P300C: ~12.5 seconds per frame at 25 steps. Roughly 10× faster than CPU for the spatial denoising step.</p>

<p>Two things worth noting about the setup code.</p>

<p><code class="language-plaintext highlighter-rouge">setup_blackhole()</code> uses <code class="language-plaintext highlighter-rouge">open_mesh_device</code> with explicit <code class="language-plaintext highlighter-rouge">physical_device_ids</code> rather than <code class="language-plaintext highlighter-rouge">open_device(device_id=0)</code>. This matters on multi-chip boards: PCIe enumeration order isn’t guaranteed, and opening only <code class="language-plaintext highlighter-rouge">device_id=0</code> leaves other chips in an unmanaged state that can interfere mid-run. Opening all chips as a mesh upfront prevents this.</p>

<p>Before this call, the code checks hwmon for a dead-ARC sentinel — temperature readings above 1000°C indicate the ARC processor has failed. If detected, TTNN initialization is skipped entirely, avoiding a five-minute timeout that previously made bad hardware look like a software hang.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Sentinel check before ttnn init
</span><span class="k">if</span> <span class="nb">any</span><span class="p">(</span><span class="n">read_hwmon_temp</span><span class="p">(</span><span class="n">p</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">1000</span> <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="n">hwmon_paths</span><span class="p">):</span>
    <span class="k">raise</span> <span class="nb">RuntimeError</span><span class="p">(</span><span class="s">"ARC appears dead — skipping TTNN init"</span><span class="p">)</span>
</code></pre></div></div>

<hr />

<h2 id="phase-25-temporal-coherence-from-the-outside">Phase 2.5: temporal coherence from the outside</h2>

<p>The TTNN UNet is a compiled monolith. You call <code class="language-plaintext highlighter-rouge">ttnn_model(latent, timestep, ...)</code> and get a noise prediction back. You cannot add TemporalTransformer blocks to its internals without modifying the <code class="language-plaintext highlighter-rouge">tt-metal</code> source.</p>

<p>The workaround: temporal attention at the latent noise-prediction level.</p>

<p>After each frame is denoised at step <code class="language-plaintext highlighter-rouge">t</code>, you have N noise predictions on CPU — shape <code class="language-plaintext highlighter-rouge">(N, 4, H/8, W/8)</code>. Before calling <code class="language-plaintext highlighter-rouge">scheduler.step()</code>, you attend across them:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">cross_frame_attention</span><span class="p">(</span><span class="n">tensors</span><span class="p">,</span> <span class="n">alpha</span><span class="o">=</span><span class="mf">0.35</span><span class="p">):</span>
    <span class="n">N</span><span class="p">,</span> <span class="n">C</span><span class="p">,</span> <span class="n">H</span><span class="p">,</span> <span class="n">W</span> <span class="o">=</span> <span class="n">tensors</span><span class="p">.</span><span class="n">shape</span>
    <span class="n">x</span> <span class="o">=</span> <span class="n">tensors</span><span class="p">.</span><span class="n">permute</span><span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="mi">3</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">).</span><span class="n">reshape</span><span class="p">(</span><span class="n">H</span> <span class="o">*</span> <span class="n">W</span><span class="p">,</span> <span class="n">N</span><span class="p">,</span> <span class="n">C</span><span class="p">).</span><span class="nb">float</span><span class="p">()</span>
    <span class="n">scale</span> <span class="o">=</span> <span class="n">C</span> <span class="o">**</span> <span class="o">-</span><span class="mf">0.5</span>
    <span class="n">attn</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">softmax</span><span class="p">(</span><span class="n">torch</span><span class="p">.</span><span class="n">bmm</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">x</span><span class="p">.</span><span class="n">transpose</span><span class="p">(</span><span class="o">-</span><span class="mi">2</span><span class="p">,</span> <span class="o">-</span><span class="mi">1</span><span class="p">))</span> <span class="o">*</span> <span class="n">scale</span><span class="p">,</span> <span class="n">dim</span><span class="o">=-</span><span class="mi">1</span><span class="p">)</span>
    <span class="n">attended</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">bmm</span><span class="p">(</span><span class="n">attn</span><span class="p">,</span> <span class="n">x</span><span class="p">).</span><span class="n">reshape</span><span class="p">(</span><span class="n">H</span><span class="p">,</span> <span class="n">W</span><span class="p">,</span> <span class="n">N</span><span class="p">,</span> <span class="n">C</span><span class="p">).</span><span class="n">permute</span><span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="mi">3</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
    <span class="k">return</span> <span class="p">((</span><span class="mf">1.0</span> <span class="o">-</span> <span class="n">alpha</span><span class="p">)</span> <span class="o">*</span> <span class="n">tensors</span> <span class="o">+</span> <span class="n">alpha</span> <span class="o">*</span> <span class="n">attended</span><span class="p">).</span><span class="n">to</span><span class="p">(</span><span class="n">tensors</span><span class="p">.</span><span class="n">dtype</span><span class="p">)</span>
</code></pre></div></div>

<p>This is cross-frame self-attention on 4-channel latent space, not on the 320-dim UNet features. It’s cheaper and less precise than the full MotionAdapter path. It’s also architecture-independent — applicable to any model that produces per-frame noise predictions without requiring access to internal feature maps.</p>

<p>The <code class="language-plaintext highlighter-rouge">--temporal-alpha</code> parameter controls the blend weight. At 0.35 (default) you get coherent structure with visible per-frame variation. At 0.6+ the background stabilizes strongly; fine detail starts to flatten.</p>

<div style="display:flex;gap:12px;flex-wrap:wrap;margin:2rem 0">
  <img src="/assets/animatediff/ocean.gif" alt="Ocean waves" style="max-width:200px;border-radius:4px" />
  <img src="/assets/animatediff/neon_dystopia.gif" alt="Neon Dystopia" style="max-width:200px;border-radius:4px" />
  <img src="/assets/animatediff/mayan_temple.gif" alt="Mayan Temple" style="max-width:200px;border-radius:4px" />
</div>

<p><em>Phase 2.5: cross-frame attention on noise predictions. The hardware does the spatial denoising; the CPU does the inter-frame coordination.</em></p>

<hr />

<h2 id="the-distillation-attempt">The distillation attempt</h2>

<p>At this point the pipeline worked: real hardware, ~12.5 s/frame, decent coherence. The obvious next target was fewer steps — if you could distill a 4-step model, you’d be at ~2 s/frame.</p>

<p>LCM (Latent Consistency Model) distillation trains a student UNet to match the teacher’s output in far fewer steps by learning the denoising trajectory directly. Four attempts were made, across different learning rates and distillation configurations.</p>

<p>All four failed to converge. The loss landscape for distillation is sharp — the student has to match a highly structured multi-step trajectory, and the gradient signal is sensitive to LR in a range that’s hard to bracket. At each attempted LR, the result was either flat (no learning) or divergence. The broken weights are archived in <code class="language-plaintext highlighter-rouge">weights/*.broken</code>.</p>

<p>The lesson from this failure wasn’t immediately obvious. It turned out the wrong question was being asked: “how do we make each step cheaper?” The right question was “how do we change the step trajectory?”</p>

<p>Euler scheduling — used by AnimateDiff-Lightning — covers the same total sigma range as PNDM but with different step spacing. With 25 steps and <code class="language-plaintext highlighter-rouge">EulerDiscreteScheduler(timestep_spacing="trailing", beta_schedule="linear")</code>, you get a different quality/structure tradeoff without needing distilled weights. The CFG=1.0 constraint that AnimateDiff-Lightning CPU requires (because the distilled adapter bakes it in) doesn’t apply to the TTNN path — the base SD 1.4 UNet benefits from full CFG=7.5 guidance regardless of scheduler.</p>

<div style="display:flex;gap:12px;flex-wrap:wrap;margin:2rem 0">
  <div style="text-align:center">
    <img src="/assets/animatediff/lcm-aurora.gif" alt="LCM attempt — aurora" style="max-width:200px;border-radius:4px" /><br />
    <small style="color:#888">LCM attempt (failed)</small>
  </div>
  <div style="text-align:center">
    <img src="/assets/animatediff/lightning-aurora.gif" alt="Lightning — aurora" style="max-width:200px;border-radius:4px" /><br />
    <small style="color:#888">Lightning / Euler (25 steps, CFG=7.5)</small>
  </div>
  <div style="text-align:center">
    <img src="/assets/animatediff/lightning-mandala.gif" alt="Lightning — mandala" style="max-width:200px;border-radius:4px" /><br />
    <small style="color:#888">Lightning / Euler — mandala</small>
  </div>
</div>

<p>Lightning mode on the TTNN path also required updating the cross-frame attention schedule. Each Euler step covers a larger sigma interval than PNDM, so a fixed-alpha blend at the wrong magnitude either collapses the frames into each other or has no effect. The implementation uses a cosine-decay alpha that starts high (strong structural agreement early) and decays toward the end (per-frame variety preserved in fine detail), and applies the blend at two points per step: once on the noise predictions before <code class="language-plaintext highlighter-rouge">scheduler.step()</code>, and a gentler pass on the resulting latents after.</p>

<div style="display:flex;gap:12px;flex-wrap:wrap;margin:2rem 0">
  <div style="text-align:center">
    <img src="/assets/animatediff/arctic-wave-standard.gif" alt="Arctic wave standard" style="max-width:200px;border-radius:4px" /><br />
    <small style="color:#888">PNDM 25 steps</small>
  </div>
  <div style="text-align:center">
    <img src="/assets/animatediff/arctic-wave-lightning.gif" alt="Arctic wave lightning" style="max-width:200px;border-radius:4px" /><br />
    <small style="color:#888">Euler 25 steps</small>
  </div>
  <div style="text-align:center">
    <img src="/assets/animatediff/supernova-standard.gif" alt="Supernova standard" style="max-width:200px;border-radius:4px" /><br />
    <small style="color:#888">PNDM — supernova</small>
  </div>
  <div style="text-align:center">
    <img src="/assets/animatediff/supernova-lightning.gif" alt="Supernova lightning" style="max-width:200px;border-radius:4px" /><br />
    <small style="color:#888">Euler — supernova</small>
  </div>
</div>

<hr />

<h2 id="phase-3-real-motionadapter-on-blackhole">Phase 3: real MotionAdapter on Blackhole</h2>

<p>Phase 2.5 cross-frame attention is real temporal coherence but it’s approximate — it operates on 4-channel latent noise predictions rather than on the 320-dim UNet intermediate features where AnimateDiff was designed to work. Phase 3 brings the full MotionAdapter to Blackhole.</p>

<p>The TTNN UNet is a monolithic <code class="language-plaintext highlighter-rouge">__call__</code>. To inject temporal attention at 7 points inside it, the approach was to replicate the orchestration outside the source file and call each block object directly:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># forward_unet_staged() — never modifies tt-metal source
</span><span class="k">for</span> <span class="n">block_idx</span><span class="p">,</span> <span class="p">(</span><span class="n">block_type</span><span class="p">,</span> <span class="n">down_block</span><span class="p">)</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="nb">zip</span><span class="p">(...)):</span>
    <span class="n">s</span><span class="p">,</span> <span class="n">res_samples</span> <span class="o">=</span> <span class="n">down_block</span><span class="p">(</span><span class="n">hidden_states</span><span class="o">=</span><span class="n">hidden_samples</span><span class="p">[</span><span class="n">i</span><span class="p">],</span> <span class="n">temb</span><span class="o">=</span><span class="n">emb</span><span class="p">,</span> <span class="p">...)</span>
    <span class="c1"># after each CrossAttnDownBlock2D: inject temporal attention
</span>    <span class="k">if</span> <span class="sa">f</span><span class="s">"down</span><span class="si">{</span><span class="n">block_idx</span><span class="si">}</span><span class="s">"</span> <span class="ow">in</span> <span class="n">temporal_kernels</span><span class="p">:</span>
        <span class="n">hidden_samples</span> <span class="o">=</span> <span class="n">_apply_temporal</span><span class="p">(</span><span class="n">hidden_samples</span><span class="p">,</span> <span class="n">temporal_kernels</span><span class="p">[</span><span class="n">key</span><span class="p">],</span> <span class="p">...)</span>
</code></pre></div></div>

<p>The 7 injection points are: down0, down1, down2 (encoder), mid (bottleneck), up0, up1, up2 (decoder). At each point, all N frame tensors are pulled from device to CPU, passed through <code class="language-plaintext highlighter-rouge">AnimateDiffTransformer3D.forward()</code> with the real pretrained motion weights, then pushed back. The spatial convolution and attention runs on Blackhole; the temporal attention runs on CPU with the full diffusers module — GroupNorm, <code class="language-plaintext highlighter-rouge">proj_in/out</code>, LayerNorm×3, positional embedding, GEGLU feedforward, all of it. No weight was modified or replaced.</p>

<p><strong>The energy explosion bug.</strong> The first Phase 3 attempt produced pure noise — every output was entirely incoherent. Two root causes:</p>

<ol>
  <li>
    <p><code class="language-plaintext highlighter-rouge">nn.Linear</code> stores weights as <code class="language-plaintext highlighter-rouge">[out_channels, in_channels]</code> but the projection needs <code class="language-plaintext highlighter-rouge">x @ w</code> where <code class="language-plaintext highlighter-rouge">w</code> is <code class="language-plaintext highlighter-rouge">[in_channels, out_channels]</code>. The weight loader wasn’t transposing. Square <code class="language-plaintext highlighter-rouge">[C, C]</code> matrices concealed this because a square matrix transposed still has the right shape — the projection runs without error but computes wrong values. The energy ratio (output energy / input energy) going into the temporal modules was above 2.0. After adding <code class="language-plaintext highlighter-rouge">.T.contiguous()</code> to the weight load, it dropped below 1.25.</p>
  </li>
  <li>
    <p>The initial <code class="language-plaintext highlighter-rouge">_apply_temporal</code> only implemented QKV + residual — not the full <code class="language-plaintext highlighter-rouge">AnimateDiffTransformer3D</code>. The real module runs GroupNorm, <code class="language-plaintext highlighter-rouge">proj_in</code>, LayerNorm×3, positional embedding, GEGLU feedforward, and <code class="language-plaintext highlighter-rouge">proj_out</code>. Replacing the partial implementation with a direct <code class="language-plaintext highlighter-rouge">module.forward()</code> call fixed the remaining energy divergence.</p>
  </li>
</ol>

<p><strong>The L1 circular buffer constraint.</strong> Running N frames sequentially through the same TTNN cross-attention block revealed a constraint: the kernel allocates static circular buffers in L1. If a previous frame’s output tensor is still resident in L1 when the same program dispatches for the next frame, the CB allocations overlap with the live buffer and the dispatch fails with an error at <code class="language-plaintext highlighter-rouge">program.cpp:1476</code>.</p>

<p>The fix is to evict each frame’s output to DRAM before the next frame runs:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">num_frames</span><span class="p">):</span>
    <span class="n">s</span><span class="p">,</span> <span class="n">res_samples</span> <span class="o">=</span> <span class="n">down_block</span><span class="p">(</span><span class="n">hidden_states</span><span class="o">=</span><span class="n">hidden_samples</span><span class="p">[</span><span class="n">i</span><span class="p">],</span> <span class="p">...)</span>
    <span class="n">s_dram</span> <span class="o">=</span> <span class="n">ttnn</span><span class="p">.</span><span class="n">to_memory_config</span><span class="p">(</span><span class="n">s</span><span class="p">,</span> <span class="n">ttnn</span><span class="p">.</span><span class="n">DRAM_MEMORY_CONFIG</span><span class="p">)</span>
    <span class="n">s</span><span class="p">.</span><span class="n">deallocate</span><span class="p">(</span><span class="bp">True</span><span class="p">)</span>
    <span class="n">new_hidden_dram</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">s_dram</span><span class="p">)</span>
</code></pre></div></div>

<p>This is an L1 management discipline that the TTNN UNet doesn’t need when processing a single frame. Sequential frame processing introduces it.</p>

<div style="display:flex;gap:12px;flex-wrap:wrap;margin:2rem 0">
  <div style="text-align:center">
    <img src="/assets/animatediff/mayan-q1-ajaw.gif" alt="Mayan Ajaw Q1 — 16fr, 4-chip" style="max-width:200px;border-radius:4px" /><br />
    <small style="color:#888">Q1: 16 frames, 4-chip, full MotionAdapter</small>
  </div>
  <div style="text-align:center">
    <img src="/assets/animatediff/mayan-q3-ajaw.gif" alt="Mayan Ajaw Q3 — full Phase 3" style="max-width:200px;border-radius:4px" /><br />
    <small style="color:#888">Q3: full Phase 3, 7 injection pts (~52 s/fr)</small>
  </div>
  <div style="text-align:center">
    <img src="/assets/animatediff/mayan-q4-ajaw.gif" alt="Mayan Ajaw Q4 — skip up1 up2" style="max-width:200px;border-radius:4px" /><br />
    <small style="color:#888">Q4: skip up1+up2 (~7.7 s/fr)</small>
  </div>
</div>

<p><em>Mayan glyph “Ajaw” across quality tiers. Q3 = full 7-point MotionAdapter on Blackhole. Q4 = skip the two decoder injection points — 6.75× faster, minor visible difference.</em></p>

<hr />

<h2 id="the-asymmetric-transfer-optimization">The asymmetric transfer optimization</h2>

<p>At 7 injection points per step with N frames, the dominant cost in Phase 3 is PCIe transfers. The obvious fix: pull all N frame tensors from device in a single call instead of N separate calls.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Batched D→H: concatenate all N frames, pull once
</span><span class="n">batched</span> <span class="o">=</span> <span class="n">ttnn</span><span class="p">.</span><span class="n">concat</span><span class="p">(</span><span class="n">dram_samples</span><span class="p">,</span> <span class="n">dim</span><span class="o">=</span><span class="mi">0</span><span class="p">)</span>   <span class="c1"># [N, 1, 2*S, C]
</span><span class="n">raw_batch</span> <span class="o">=</span> <span class="n">ttnn</span><span class="p">.</span><span class="n">to_torch</span><span class="p">(</span><span class="n">batched</span><span class="p">).</span><span class="nb">float</span><span class="p">()</span>    <span class="c1"># one PCIe transfer
</span><span class="n">batched</span><span class="p">.</span><span class="n">deallocate</span><span class="p">(</span><span class="bp">True</span><span class="p">)</span>
</code></pre></div></div>

<p>This eliminated N-1 PCIe round-trips per injection point. Measured speedup: <strong>1.94×</strong> — from ~101 to ~52 seconds per frame on QB2 (4×P300C, 8 frames, 25 steps).</p>

<p>The H→D direction can’t be batched the same way. The approach — push a single <code class="language-plaintext highlighter-rouge">[N, ...]</code> tensor to device and use <code class="language-plaintext highlighter-rouge">ttnn.split</code> to distribute it — fails because <code class="language-plaintext highlighter-rouge">ttnn.split</code> produces parent-buffer views. The downstream resnet reshard kernel can’t reroute those views to the expected shard grid. Per-frame <code class="language-plaintext highlighter-rouge">to_device()</code> is the safe path.</p>

<p>Batched D→H, per-frame H→D. The optimization is real but asymmetric — a TTNN-specific constraint with no obvious signal from the API that one direction works and the other doesn’t.</p>

<hr />

<h2 id="the-injection-point-cost-distribution">The injection point cost distribution</h2>

<p>Not all 7 injection points cost the same. The decoder blocks — up1 at 32×32 spatial resolution with C=1280, and up2 at 64×64 with C=640 — account for roughly 80% of the CPU transformer time. The encoder and mid blocks work at smaller spatial dimensions and run proportionally faster.</p>

<p><code class="language-plaintext highlighter-rouge">--motion-adapter-skip up1 up2</code> bypasses the two costliest points. Result: <strong>~7.7 seconds per frame</strong> vs ~52 for the full Phase 3 run — a 6.75× speedup. This is also faster than Phase 2.5 (~12.5 s/frame). Encoder and mid-block temporal attention is retained. Decoder-side coherence is slightly reduced but the difference is minor for most prompts.</p>

<p>Lightning mode doesn’t help here: combining <code class="language-plaintext highlighter-rouge">--lightning</code> with <code class="language-plaintext highlighter-rouge">--motion-adapter</code> yields ~50.6 s/frame, roughly the same as standard PNDM. The bottleneck is the CPU bridge calls per step, not the number of scheduler steps. Changing the solver doesn’t change how many times you have to cross PCIe to run the temporal modules.</p>

<div style="display:flex;gap:12px;flex-wrap:wrap;margin:2rem 0">
  <div style="text-align:center">
    <img src="/assets/animatediff/mayan-q1-imix.gif" alt="Imix Q1" style="max-width:200px;border-radius:4px" /><br />
    <small style="color:#888">"Imix" Q1</small>
  </div>
  <div style="text-align:center">
    <img src="/assets/animatediff/mayan-q4-imix.gif" alt="Imix Q4" style="max-width:200px;border-radius:4px" /><br />
    <small style="color:#888">"Imix" Q4 — skip up1+up2</small>
  </div>
</div>

<table>
  <thead>
    <tr>
      <th>Mode</th>
      <th>s/frame</th>
      <th>8fr total</th>
      <th>Notes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Phase 2.5 (PNDM)</td>
      <td>~12.5</td>
      <td>~100s</td>
      <td>cross-frame blend at noise level</td>
    </tr>
    <tr>
      <td>Phase 2.5 (Euler)</td>
      <td>~12.0</td>
      <td>~96s</td>
      <td>different trajectory, same cost</td>
    </tr>
    <tr>
      <td>Phase 3 full</td>
      <td>~52</td>
      <td>~416s</td>
      <td>all 7 injection pts, batched D→H</td>
    </tr>
    <tr>
      <td><strong>Phase 3 skip up1+up2</strong></td>
      <td><strong>~7.7</strong></td>
      <td><strong>~62s</strong></td>
      <td>skip 2 decoder pts, faster than 2.5</td>
    </tr>
    <tr>
      <td>Phase 3 + Lightning</td>
      <td>~50.6</td>
      <td>~405s</td>
      <td>CPU bridge cost dominates</td>
    </tr>
  </tbody>
</table>

<p><em>All timings on QB2 (4×P300C), 8 frames, 512×512, warm kernels.</em></p>

<hr />

<h2 id="4-chip-parallelism-on-the-qb2">4-chip parallelism on the QB2</h2>

<p>The QB2 board carries four P300C Blackhole chips. The pipeline uses them in two distinct ways.</p>

<p><strong>In-process frame sharding (Phase 2.5).</strong> Within a single generation run, the TTNN UNet denoising loop shards frames across all four chips in the same process. The compiled TTNN UNet expects exactly <code class="language-plaintext highlighter-rouge">batch_size=2</code> (CFG uncond+cond) per chip, so the mechanism is one CFG-doubled frame per chip per pass, in chunks of 4:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># plan_frame_sharding returns (True, 4) on a 4-chip mesh
</span><span class="n">_use_sharding</span><span class="p">,</span> <span class="n">_chunk</span> <span class="o">=</span> <span class="n">plan_frame_sharding</span><span class="p">(</span><span class="n">num_frames</span><span class="p">,</span> <span class="n">num_chips</span><span class="p">)</span>

<span class="k">for</span> <span class="n">c0</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="n">num_frames</span><span class="p">,</span> <span class="n">_chunk</span><span class="p">):</span>
    <span class="c1"># CFG-double each frame in this chunk, then shard across chips
</span>    <span class="n">stacked_dev</span> <span class="o">=</span> <span class="n">shard_frames_to_device</span><span class="p">(</span><span class="n">cfg_latents</span><span class="p">,</span> <span class="n">device</span><span class="p">,</span> <span class="p">...)</span>
    <span class="c1"># ShardTensorToMesh(dim=0) — chip K gets rows [K*2 : K*2+2]
</span>    <span class="n">ttnn_out</span> <span class="o">=</span> <span class="n">ttnn_model</span><span class="p">(</span><span class="n">stacked_dev</span><span class="p">,</span> <span class="p">...)</span>
    <span class="n">frame_outputs</span> <span class="o">=</span> <span class="n">gather_frames_from_device</span><span class="p">(</span><span class="n">ttnn_out</span><span class="p">,</span> <span class="n">device</span><span class="p">,</span> <span class="n">_chunk</span><span class="p">)</span>
</code></pre></div></div>

<p>For 8 frames on 4 chips, this runs 2 sharded passes per denoising step instead of 8 serial UNet calls. For 16 frames it runs 4 passes. The TTNN UNet dispatches in parallel across all chips; each call to <code class="language-plaintext highlighter-rouge">ttnn_model(...)</code> fans out the work.</p>

<p>The efficiency benefit is most visible with 16 frames: at 8 frames the per-step overhead is proportionally larger; at 16 frames it’s amortized across more useful work. Measured result: <strong>~5.4 s/frame at 16 frames</strong> vs ~12.5 s/frame at 8 frames — a 2.3× per-frame improvement from the same hardware just by generating a longer clip.</p>

<p><code class="language-plaintext highlighter-rouge">num_frames</code> must be divisible by <code class="language-plaintext highlighter-rouge">num_chips</code>. A partial final chunk would place fewer frames on the mesh than the compiled kernel expects and fail with a cryptic dispatch error. The pipeline rejects non-divisible counts early with a clear message listing the valid frame counts for the current rig.</p>

<p><strong>Multi-process parallel throughput (all modes).</strong> The <code class="language-plaintext highlighter-rouge">--device-id INT</code> flag pins a <code class="language-plaintext highlighter-rouge">generate.py</code> process to a specific chip (0-indexed). Running four processes simultaneously — one per chip — generates four independent clips in parallel at full single-chip speed. The World’s Fair benchmark script uses this: 9 prompts across quality tiers dispatched 4-at-a-time, each batch of 4 completing in ~105 seconds wall-clock. Total throughput for a 40-clip batch (20 glyphs × 2 tiers in the Maya benchmark): ~5 batches × 105s = ~525s, versus the ~2100s it would take serially.</p>

<p>This is a different axis of parallelism from in-process sharding — throughput over multiple clips rather than lower latency on one clip. The two compose: you can run 4-chip in-process sharding (faster per-frame) on each of those parallel processes simultaneously.</p>

<div style="display:flex;gap:12px;flex-wrap:wrap;margin:2rem 0">
  <div style="text-align:center">
    <img src="/assets/animatediff/nyc-1939.gif" alt="NYC 1939 World's Fair" style="max-width:200px;border-radius:4px" /><br />
    <small style="color:#888">New York 1939 — Q1 tier, 16 frames, 4-chip</small>
  </div>
  <div style="text-align:center">
    <img src="/assets/animatediff/osaka-1970.gif" alt="Osaka 1970 World's Fair" style="max-width:200px;border-radius:4px" /><br />
    <small style="color:#888">Osaka 1970 — Q1 tier, 16 frames, 4-chip</small>
  </div>
  <div style="text-align:center">
    <img src="/assets/animatediff/paris-1889.gif" alt="Paris 1889 World's Fair" style="max-width:200px;border-radius:4px" /><br />
    <small style="color:#888">Paris 1889 — Q1 tier, 16 frames, 4-chip</small>
  </div>
</div>

<p><em>World’s Fair Q1 tier: generated with 4-chip in-process sharding, 16 frames, Phase 3 MotionAdapter, PNDM 25 steps. The 16-frame sharded path (~5.4 s/frame) is what made this tier feasible to run across 9 prompts.</em></p>

<hr />

<h2 id="chain-mode-continuity-across-prompts">Chain mode: continuity across prompts</h2>

<p>One of the later additions was <code class="language-plaintext highlighter-rouge">--chain</code> — a way to carry visual continuity from one generation to the next without any explicit conditioning.</p>

<p>At the end of a run, the final denoised latents are saved (<code class="language-plaintext highlighter-rouge">--chain-save</code>). At the start of the next run, those latents are blended into the base seed noise before denoising begins (<code class="language-plaintext highlighter-rouge">--chain-from</code>). The blend is frame-averaged (preserving coarse spatial layout), then renormalized to unit standard deviation so the scheduler’s sigma scaling sees the expected noise distribution:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">prev_mean</span> <span class="o">=</span> <span class="n">prev</span><span class="p">.</span><span class="n">mean</span><span class="p">(</span><span class="n">dim</span><span class="o">=</span><span class="mi">0</span><span class="p">,</span> <span class="n">keepdim</span><span class="o">=</span><span class="bp">True</span><span class="p">).</span><span class="nb">float</span><span class="p">()</span>   <span class="c1"># average across frames
</span><span class="n">mixed</span> <span class="o">=</span> <span class="p">(</span><span class="mf">1.0</span> <span class="o">-</span> <span class="n">alpha</span><span class="p">)</span> <span class="o">*</span> <span class="n">base_noise</span> <span class="o">+</span> <span class="n">alpha</span> <span class="o">*</span> <span class="n">prev_mean</span>
<span class="n">result</span> <span class="o">=</span> <span class="n">mixed</span> <span class="o">/</span> <span class="n">mixed_std</span>                            <span class="c1"># renormalize
</span></code></pre></div></div>

<p>A key invariant discovered during development: the blended latents must be renormalized before use. An earlier version normalized per-channel before the frame average, which reduced signal standard deviation from ~0.28 to ~0.03 — less than 2% of the final variance. The chain signal was perceptually invisible. Without per-channel normalization, alpha=0.35 produces detectable (~15%) correlation with the previous layout.</p>

<div style="display:flex;gap:12px;flex-wrap:wrap;margin:2rem 0">
  <img src="/assets/animatediff/unisphere-1964.gif" alt="Unisphere 1964" style="max-width:200px;border-radius:4px" />
  <img src="/assets/animatediff/unisphere-2000.gif" alt="Unisphere 2000" style="max-width:200px;border-radius:4px" />
  <img src="/assets/animatediff/unisphere-2026.gif" alt="Unisphere 2026" style="max-width:200px;border-radius:4px" />
  <img src="/assets/animatediff/unisphere-2064.gif" alt="Unisphere 2064" style="max-width:200px;border-radius:4px" />
</div>

<p><em>The Unisphere chain: 4 independent generations across 100 years of imagined World’s Fairs. Each run inherits the coarse spatial layout of the previous one via latent blending. No explicit conditioning — just latent continuity.</em></p>

<hr />

<h2 id="worlds-fair-showcase">World’s Fair showcase</h2>

<p>With the full pipeline working, a batch generation script produced a “World’s Fair” showcase: 9 historical and speculative fair prompts, run across three quality tiers, plus the Unisphere chain.</p>

<div style="display:flex;gap:12px;flex-wrap:wrap;margin:2rem 0">
  <div style="text-align:center">
    <img src="/assets/animatediff/paris-1889.gif" alt="Paris 1889" style="max-width:200px;border-radius:4px" /><br />
    <small style="color:#888">Paris 1889</small>
  </div>
  <div style="text-align:center">
    <img src="/assets/animatediff/nyc-1939.gif" alt="NYC 1939" style="max-width:200px;border-radius:4px" /><br />
    <small style="color:#888">New York 1939</small>
  </div>
  <div style="text-align:center">
    <img src="/assets/animatediff/osaka-1970.gif" alt="Osaka 1970" style="max-width:200px;border-radius:4px" /><br />
    <small style="color:#888">Osaka 1970</small>
  </div>
</div>

<p>Full gallery at <a href="https://tenstorrent.github.io/tt-animatediff/worlds-fair.html">tenstorrent.github.io/tt-animatediff/worlds-fair.html</a>.</p>

<hr />

<h2 id="what-the-tt-hardware-specifically-required">What the TT hardware specifically required</h2>

<p>Five things about this implementation are directly shaped by the hardware:</p>

<p><strong>1. <code class="language-plaintext highlighter-rouge">open_mesh_device</code> over <code class="language-plaintext highlighter-rouge">open_device</code>.</strong> On multi-chip boards, opening a single device by ID leaves other chips unmanaged. The sentinel check for dead-ARC before TTNN initialization avoids a 5-minute timeout that looked like a software hang.</p>

<p><strong>2. The orchestration replication pattern.</strong> The TTNN UNet is a compiled monolith. When you can’t inject hooks into it, you replicate the orchestration outside it — call the same block objects in the same order from your own code, inserting temporal attention between them. <code class="language-plaintext highlighter-rouge">forward_unet_staged()</code> is ~250 lines of this. Nothing in <code class="language-plaintext highlighter-rouge">tt-metal</code> was modified.</p>

<p><strong>3. Per-frame DRAM eviction between sequential UNet calls.</strong> A compiled cross-attention kernel allocates static circular buffers in L1. Running N frames sequentially through the same kernel requires evicting each frame’s output to DRAM before dispatching the next, or the static CB allocations conflict with live L1 tensors.</p>

<p><strong>4. Asymmetric transfer batching.</strong> Pulling all N frame tensors from device to CPU can be batched (concatenate → single <code class="language-plaintext highlighter-rouge">ttnn.to_torch</code>). Pushing them back cannot — <code class="language-plaintext highlighter-rouge">ttnn.split</code> produces views incompatible with the downstream reshard kernel. Measure both directions independently before assuming a batching optimization is symmetric.</p>

<p><strong>5. Injection point cost is not uniform.</strong> The two large-spatial-dimension decoder blocks dominate the CPU bridge cost. Profiling before deciding how much of a staged forward pass to use is not optional.</p>

<hr />

<h2 id="whats-here-now">What’s here now</h2>

<p>The code is at <a href="https://github.com/tenstorrent/tt-animatediff">github.com/tenstorrent/tt-animatediff</a>. The full benchmark breakdown is at <a href="https://tenstorrent.github.io/tt-animatediff/benchmarks.html">tenstorrent.github.io/tt-animatediff/benchmarks.html</a>.</p>

<p>Three things you can do with it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># CPU — any machine, no hardware</span>
python examples/generate.py <span class="nt">--mode</span> cpu <span class="nt">--prompt</span> <span class="s2">"ocean waves at sunset, cinematic"</span>

<span class="c"># Blackhole — full MotionAdapter, fast path</span>
python examples/generate.py <span class="nt">--motion-adapter</span> <span class="nt">--motion-adapter-skip</span> up1 up2 <span class="se">\</span>
  <span class="nt">--prompt</span> <span class="s2">"aurora borealis over a frozen lake, cinematic 4K"</span>

<span class="c"># Gradio UI — local or HuggingFace Spaces</span>
python app.py
</code></pre></div></div>

<p>The architecture mismatch, the distillation failure, the L1 circular buffer, the asymmetric transfer constraint — none of these are in the happy-path documentation. They’re in the changelogs and the source comments. This post is the rest of the story.</p>]]></content><author><name>Taylor Singletary</name></author><summary type="html"><![CDATA[From wrong architecture to working video on Blackhole P300C — a complete account of bringing AnimateDiff to TT hardware: what broke, what we tried, what the hardware forced us to do differently, and what it looks like now.]]></summary></entry></feed>