<?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://zhenyu.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://zhenyu.github.io/" rel="alternate" type="text/html" /><updated>2026-09-03T04:36:17+00:00</updated><id>https://zhenyu.github.io/feed.xml</id><title type="html">Zhenyu Sha</title><subtitle>Notes on ML infrastructure, execution contracts, control planes, data lifecycle, and system boundaries</subtitle><author><name>Zhenyu Sha</name></author><entry><title type="html">Why a GPU Profiling Capture Turned into a Distributed Systems Problem</title><link href="https://zhenyu.github.io/2026/09/02/why-a-gpu-profiling-capture-turned-into-a-distributed-systems-problem/" rel="alternate" type="text/html" title="Why a GPU Profiling Capture Turned into a Distributed Systems Problem" /><published>2026-09-02T00:00:00+00:00</published><updated>2026-09-02T00:00:00+00:00</updated><id>https://zhenyu.github.io/2026/09/02/why-a-gpu-profiling-capture-turned-into-a-distributed-systems-problem</id><content type="html" xml:base="https://zhenyu.github.io/2026/09/02/why-a-gpu-profiling-capture-turned-into-a-distributed-systems-problem/"><![CDATA[<blockquote>
  <p>Starting from the ideas behind Meta’s MAIProf, I redesigned capture ownership, process discovery, and trace delivery for Kubernetes.</p>
</blockquote>

<p>A training job has already been running for hours when throughput suddenly starts to fluctuate. At that point, the thing you usually want is not to stop the job, add a pile of profiler flags, and try to reproduce the issue. You want to take a snapshot of what is happening right now:</p>

<blockquote>
  <p>Start capturing for a few seconds, now, and bring back GPU traces from every rank.</p>
</blockquote>

<p>On a single machine, this sounds like one profiler call. On Kubernetes, it quickly turns into a distributed systems problem: training processes are spread across nodes, a single Pod may contain multiple <code class="language-plaintext highlighter-rouge">torchrun</code> workers, sending a request does not mean Kineto actually accepted it, and a file appearing on disk does not mean it is complete—or that downstream analysis knows which rank it belongs to.</p>

<p>I did not start from scratch. The real starting point was Meta’s 2022 PyTorch Blog post: <a href="https://pytorch.org/blog/performance-debugging-of-production-pytorch-models-at-meta/">Performance Debugging of Production PyTorch Models at Meta</a>.</p>

<hr />

<h2 id="meta-gave-me-the-problem-decomposition-not-an-implementation-i-could-copy">Meta gave me the problem decomposition, not an implementation I could copy</h2>

<p>That post describes the overall MAIProf idea: a user submits a profiling request; a Profiling Service discovers the GPU hosts running the training job and broadcasts the request to Monitoring Daemons on those hosts; Kineto generates one trace per GPU, uploads the traces to object storage, and the system analyzes them together afterward.</p>

<p>What mattered most to me was the problem decomposition rather than any individual component: <strong>profiling can be an independent service, and a capture should be treated as a job-wide operation rather than something attached to one process.</strong></p>

<p>The public building blocks cover both ends of the pipeline. PyTorch <code class="language-plaintext highlighter-rouge">torch.profiler</code> and <a href="https://github.com/pytorch/kineto">Kineto</a> handle capture, <a href="https://github.com/facebookincubator/dynolog">dynolog</a> provides the daemon-side path, and <a href="https://github.com/facebookresearch/HolisticTraceAnalysis">HTA</a> analyzes traces. What is not public is the orchestration layer in the middle: how to discover a job, expand it into all profiling targets, track request lifecycle, and deliver a set of traces as one trustworthy capture.</p>

<p>More importantly, the public path carries a clear Slurm shape. Kineto uses <code class="language-plaintext highlighter-rouge">SLURM_JOB_ID</code> to identify the job, and upstream <code class="language-plaintext highlighter-rouge">unitrace.py</code> expands allocation hosts through <code class="language-plaintext highlighter-rouge">squeue</code> and <code class="language-plaintext highlighter-rouge">scontrol</code>. Kubernetes gives us something different: Pods that are rebuilt, multiple processes inside one container, selectors that can change over time, and a completely different controller failure model. The workload identity, process topology, and failure semantics are different, so the control plane cannot be translated one-for-one.</p>

<p>The per-job coordinator, ephemeral capture state, frozen target snapshots, at-least-once dispatch, and manifest-based delivery described below are therefore Kubernetes-specific trade-offs I made while following the same problem decomposition. They are not claims about Meta’s internal implementation. The <code class="language-plaintext highlighter-rouge">profiling-operator</code> described here is not currently open source either; this post is about the reasoning process, not a code release.</p>

<p>There is one more boundary that is easy to blur. MAIProf’s “No source-code change required” does not mean “attach to any arbitrary process after the fact.” My implementation also does not require changes to model source code, but the training workload must be armed at creation time with a hook, environment variables, and a shared directory so that Kineto enters daemon mode. You arm the workload once, then repeatedly trigger captures later without interrupting training.</p>

<h2 id="in-the-first-version-one-capture-was-one-cr">In the first version, one capture was one CR</h2>

<p>The first version followed the most natural Kubernetes Controller design. One capture was a <code class="language-plaintext highlighter-rouge">ProfileRun</code>. A central Operator watched it, discovered all target Pods, fanned out to the relevant nodes, and wrote per-rank progress back to <code class="language-plaintext highlighter-rouge">status</code>.</p>

<p>The benefits were real. Users could watch progress with familiar <code class="language-plaintext highlighter-rouge">kubectl get -w</code>. If the Operator lost leadership, a new leader could recover from state already stored in the API Server. Kubernetes had already solved persistence, watch delivery, concurrent updates, and access control for me. At first, there seemed to be no reason to build something else.</p>

<p>The problem appeared while I was reading through the Reconcile path. The controller had a single worker by default, and fan-out was synchronous. If one node silently dropped packets, one Reconcile could occupy the only worker for a long time. The next item in the queue might not be another rank from the same job. It could be a completely unrelated training job elsewhere in the cluster. A node-local data-plane failure could therefore become a cross-job control-plane stall.</p>

<p>This was not an incident that forced the redesign. It was a risk visible directly from the code path, and I did not want to wait for it to block unrelated workloads in production.</p>

<p>The cheapest fix was obvious: increase <code class="language-plaintext highlighter-rouge">MaxConcurrentReconciles</code>, add timeouts to fan-out, and move node calls into goroutines. That would reduce head-of-line blocking, but it would not change the actual boundary of the problem. As long as I wanted a capture to continue across leader failover, I still had to persist the target snapshot, timing origin, deadlines, and per-target state. Every rank transition would still become an API Server write. None of the temporal protocol disappeared.</p>

<p>More workers would only allow more captures to occupy central control-plane resources at the same time. They would not isolate a failure to one job.</p>

<p>That is why I did not stop at “optimize Reconcile.” One capture naturally belongs to one attempt of one training job. Target membership, permissions, archive prefixes, and lifecycle are all job-scoped. The real mistake was not a concurrency setting. It was where orchestration lived.</p>

<h2 id="then-i-gave-capture-back-to-the-job">Then I gave capture back to the job</h2>

<p>In the second version, the central Operator only owns the slow path. It discovers training jobs that have profiling capability and creates one coordinator for each <code class="language-plaintext highlighter-rouge">(job, attempt)</code>. Capture requests go directly to that coordinator. The coordinator expands targets in memory, fans out to node agents, waits for traces, and decides when the capture converges.</p>

<p>The node agent still owns only the things that are inherently local: PIDs, UNIX sockets, dynolog, and files.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                         create / garbage collect
Lifecycle Operator  ------------------------------&gt;  Per-job Coordinator
                                                        ^          |
                                                        |          | fan-out / query
                                             Start /    |          v
                                             Watch      |      Node Agents
                                                        |          |
CLI / Gateway  -----------------------------------------+          v
                                                               stock dynolog
                                                                    |
                                                                    v
                                                              Kineto workers
                                                                    |
                                                                    v
                                                            Node-local traces
                                                                    |
                                                        verify / archive
                                                                    |
                                                                    v
                                                             Shared storage
                                                                    |
Per-job Coordinator  -------- manifests ----------------------------+--&gt; HTA analysis
</code></pre></div></div>

<p>The benefit of this split was clear. The capture hot path no longer writes to the API Server. One stuck node affects one job instead of the entire cluster. Restarting or re-electing the central Operator does not interrupt captures already driven by a coordinator.</p>

<p>The bill was equally clear, and it was not cheap.</p>

<p>The first version’s native <code class="language-plaintext highlighter-rouge">kubectl get -w</code> became a read API on the coordinator. The failover recovery I got for free from CR persistence was not carried over either. If the central Operator changes leaders, nothing happens to an in-flight capture. But if the coordinator itself dies, an unfinished capture is abandoned. A replacement coordinator can only answer <code class="language-plaintext highlighter-rouge">Unknown</code> for that old capture.</p>

<p>Every armed job also keeps a coordinator Pod alive, which consumes resources and tenant quota. If a request arrives before the Pod is Ready or before its endpoint exists, there is a cold-start window. The external interface becomes two layers rather than one: a <code class="language-plaintext highlighter-rouge">CaptureCoordinator</code> CRD for lifecycle and a gRPC API for individual captures.</p>

<p>The redesign also changed how I think about the Kubernetes API boundary. The API Server is a good place for desired state and slow lifecycle state, but that does not mean every short-lived distributed protocol should be encoded into CR status. The issue is not simply that “the API Server is slow.” The capture hot path, its state ownership, and its failure domain all belong to the job. Persisting them into a global control plane buys cross-failure recovery and cross-job coupling at the same time.</p>

<p><strong>The second version did not remove state from the system. It changed who owns that state and where it is persisted.</strong> I gave up some of the first version’s durability—<code class="language-plaintext highlighter-rouge">kubectl get -w</code> and resuming after control-plane failover—and paid for resident resources plus a more complex API surface. In return, I got isolation and hot-path independence.</p>

<p>To make sure a capture always terminates, the coordinator freezes the target snapshot at the end of prepare. Pods that match the selector afterward—whether from scale-out, rebuild, or retry—belong to the next capture. Otherwise the target set could keep changing while the capture is running, and the capture might never finish.</p>

<p>I also explicitly gave up several tempting features. Each target starts as soon as it can; there is no broadcast <code class="language-plaintext highlighter-rouge">startTime</code> and no barrier, so cross-rank alignment is left to analysis. Dispatch is at-least-once, keyed by <code class="language-plaintext highlighter-rouge">captureInstanceID + targetID</code>; a duplicate trace is acceptable, while a missed trace is the real loss. Capture windows are wall-clock only because iteration windows require the training loop to cooperate through <code class="language-plaintext highlighter-rouge">profiler.step()</code>. Cancellation is logical only, because stock dynolog cannot retract a configuration that has already been dispatched.</p>

<p>These are not restatements of Meta’s design. They are the boundaries I chose for this Kubernetes implementation.</p>

<h2 id="after-the-redesign-the-cluster-started-changing-the-answer">After the redesign, the cluster started changing the answer</h2>

<p>If I tell the story only in the order above, it is easy to make it sound like one clean redesign solved the architecture. The actual timeline was less tidy.</p>

<p>The first version’s global blocking risk came from code inspection. After the second version took shape, a different set of problems started appearing in real end-to-end runs. The single-Pod, multi-process under-capture risk was confirmed by acceptance testing. The distinction between matched and triggered came from upstream source reading, and the matched-but-busy branch later showed up in the cluster. The announce/verify split came from a real data-loss incident and took three rounds to fix.</p>

<p>So the rest of the story is not “the architecture anticipated everything.” It is a structural redesign followed by a sequence of runtime evidence that kept correcting the implementation.</p>

<h2 id="one-pod-is-not-one-profiler">One Pod is not one profiler</h2>

<p>The most natural Kubernetes target is a Pod, but the profiler actually talks to processes. In a single-process container those happen to line up, which makes “one target per Pod” look completely correct.</p>

<p>Now replace that workload with a common <code class="language-plaintext highlighter-rouge">torchrun --nproc-per-node=8</code> setup. One container has eight workers. The old logic can still report a beautiful <code class="language-plaintext highlighter-rouge">SUCCEEDED 1/1</code> while capturing only one eighth of the job. The dangerous part is not that it fails. It is that it succeeds so quietly.</p>

<p>The fix cannot be “have the agent scan <code class="language-plaintext highlighter-rouge">/proc</code> and guess.” PID 1 is often just the launcher, the agent may live in a different PID namespace, and it cannot reliably answer which child process corresponds to which global rank.</p>

<p>The final design lets the coordinator expand a Pod into multiple logical targets using the local world size and base global rank recorded at creation time. Each worker then registers its own rank, PID, and job attempt through a very thin hook before <code class="language-plaintext highlighter-rouge">import torch</code>.</p>

<p>This is where “arm at creation time, trigger at runtime” actually carries the architecture. If you wait until runtime to infer process identity, it is already too late.</p>

<h2 id="matched-does-not-mean-the-shutter-has-fired">Matched does not mean the shutter has fired</h2>

<p>The node agent ultimately asks stock dynolog for an on-demand trace. Its response exposes two fields that are easy to collapse into one meaning: <code class="language-plaintext highlighter-rouge">processesMatched</code> says dynolog found the target process, while <code class="language-plaintext highlighter-rouge">activityProfilersTriggered</code> says the configuration actually entered the profiler’s pending slot.</p>

<p>If the profiler is busy, the process can be matched without being triggered. Treating “matched” as success tells the user that capture started, and then no file ever arrives.</p>

<p>The state machine therefore has to distinguish “queued,” “waiting for an available slot,” and “not matched yet.” Even then, the strongest statement it can make is “queued by dynolog.” It cannot claim that Kineto consumed the configuration.</p>

<p>That is also why I kept stock dynolog instead of forking it to obtain a prettier state model. The cost is an observability ceiling: I cannot see exactly when Kineto consumes a configuration, I cannot see poll liveness directly, and I do not have a physical cancel. The benefit is that I do not own a private protocol tied to upstream memory layout and version details. I would rather stop the state machine at the strongest fact I can actually observe than invent a stronger “received” state.</p>

<h3 id="a-test-that-would-always-pass">A test that would always pass</h3>

<p>This path also produced a subtler false success. Kineto’s activity-type key is singular: <code class="language-plaintext highlighter-rouge">ACTIVITY_TYPES</code>, not the more natural-looking <code class="language-plaintext highlighter-rouge">ACTIVITIES_TYPES</code>. With the wrong spelling, upstream does not fail the capture. It ignores the field and falls back to a default set.</p>

<p>That default set happened to be a superset of what the request asked for. As a result, the test “all requested activity types are present” passed whether the mechanism worked or not.</p>

<p>What finally caught the bug was a negative assertion: verify that an explicitly excluded <code class="language-plaintext highlighter-rouge">python_function</code> activity was <strong>not</strong> present.</p>

<p>That became a simple rule I now use when judging tests:</p>

<p><strong>If a test sees the same result whether the mechanism exists or not, it is not a test yet.</strong></p>

<h2 id="there-is-no-capture-complete-acknowledgement-so-i-had-to-watch-the-file">There is no “capture complete” acknowledgement, so I had to watch the file</h2>

<p>dynolog/Kineto can accept an on-demand configuration, but it does not provide a reliable completion event suitable for a control plane. When a trace finishes, the durable fact is that a temporary file gets renamed to its final name. The protocol does not tell the coordinator, “capture completed.”</p>

<p>The node agent therefore has to observe that final filesystem fact.</p>

<p>The first implementation discovered and validated files sequentially in one scan. The problem is that validating a large trace is O(bytes). If reading one trace takes close to a hundred seconds, files that landed afterward may not even get globbed before the scan finishes.</p>

<p>The real failure happened with two concurrent captures. Both sets of trace files were complete on disk, but one capture still converged to <code class="language-plaintext highlighter-rouge">MissingTrace</code>.</p>

<p>The first fix anchored the deadline to the point when trace activation was actually observed. The second added a hold that delayed the terminal state while a file was being verified. Both were logically reasonable. Both still failed under cluster retest.</p>

<p>The reason was deeper: both protections required an observation from the agent before they could activate, while the observation itself was being starved by validation of the previous large file.</p>

<p>The third fix finally touched the root cause. The agent first performs a fast announce of every file seen in the current scan, then verifies asynchronously. Even the first announce/verify split was not enough because both steps still shared one non-reentrant ticker goroutine. Only after Announce and Verify moved onto independent cadences did O(bytes) validation stop blocking the appearance of new evidence.</p>

<p>The lesson from that incident was not “validation is slow, so use goroutines.”</p>

<p>It was:</p>

<p><strong>Extending how long the control plane is willing to wait cannot fix how slowly the data plane is able to observe.</strong></p>

<p>A hold triggered by observation can never be more reliable than the latency of that observation itself.</p>

<p>A verified file still cannot immediately be declared successful. Local disk on a training node is a good place for Kineto to land bytes, but it is not a durable delivery address. The agent has to enforce this order:</p>

<blockquote>
  <p>verify → archive → publish manifest</p>
</blockquote>

<p>That sequence copies the trace one extra time, briefly consumes two copies of the bytes, and delays the final result by the archive step. But if the system declares success before the copy, status can already say <code class="language-plaintext highlighter-rouge">Archived</code> while the only bytes are still trapped on a node that may be reclaimed.</p>

<p>The extra I/O buys a completion semantic the system can actually keep.</p>

<p>The coordinator also pulls completion status from agents rather than having every agent push events. Push has lower latency, but it requires reverse-serving paths, identity, and network policy. More importantly, a transiently unavailable coordinator can permanently miss an edge-triggered event. Pull adds polling traffic and a few seconds of convergence delay, but manifests are level-triggered facts. Missing one poll does not make an already-existing manifest disappear.</p>

<h2 id="a-trace-eventually-needs-a-receipt">A trace eventually needs a receipt</h2>

<p>Once traces reach shared storage, the easiest analysis path is to scan a directory and read every JSON file in it. But a directory can only tell you “these files exist.” It cannot prove that they belong to this capture, that all expected ranks are present, or that an old trace from a previous attempt was not picked up by mistake.</p>

<p>The manifest is the receipt connecting logical rank identity to physical bytes.</p>

<p>It records the target, rank, attempt, archive URI, size, and checksum. The analysis Job reads only files referenced by the manifest and verifies the checksum again before consumption. A file that exists but is not referenced by the manifest is intentionally ignored.</p>

<p>The cost is that the manifest becomes a contract between capture and analysis that must be maintained. Orphan traces are deliberately excluded. But that explicit coupling is still better than having every downstream consumer scan directories and independently guess rank mapping.</p>

<p>The analysis path also rejects an empty manifest. A program exiting with status 0 proves only that it did not crash. It does not prove that it analyzed any data.</p>

<p>At this point, the invariant that runs through the entire system can finally be stated directly:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>request dispatched
≠ profiler triggered
≠ trace file appeared
≠ trace validated
≠ trace delivered

capture converged =
  every frozen target has either
  (a) a verified, archived, identity-bound artifact in the manifest
  or
  (b) an explicit terminal failure

capture fully succeeded =
  every frozen target took branch (a)
</code></pre></div></div>

<p>An explicit terminal failure means the capture converged. It does not mean the capture fully succeeded. The system can only say “we got the whole shot” when every logical target in the frozen snapshot maps to a verified, archived, identity-bound artifact in the manifest.</p>

<h2 id="the-final-toy-job-was-a-smoke-test-not-a-benchmark">The final toy job was a smoke test, not a benchmark</h2>

<p>I finished by running the full pipeline on a deliberately small DDP job: two machines, two workers per training container, four ranks running a real collective. Four traces went through local observation, validation, and archival, and then an HTA Job consumed them through the manifest.</p>

<p><img src="/assets/capture-69f61293-inline.png" alt="Toy DDP capture: four logical ranks verified, archived, and analyzed together" /></p>

<p>The performance ratios in this figure are not useful for workload diagnosis. The job is an intentionally synthetic communication bottleneck with very little compute. The NCCL kernel shape and roughly 1.5 GB/s effective bandwidth are much closer to Pod-network TCP behavior than NVLink.</p>

<p>It does not represent a real model, and it should not be used to judge whether a production training job is healthy.</p>

<p>The footer’s <code class="language-plaintext highlighter-rouge">ProfilerStep markers: 0 in every rank -- BY DESIGN</code> is also not a defect. This system provides wall-clock windows only. Iteration-scoped capture would require the training loop to call <code class="language-plaintext highlighter-rouge">profiler.step()</code>, which is explicitly outside this phase.</p>

<p>What the toy job proves is only the pipeline:</p>

<ul>
  <li>multiple workers inside one Pod were not collapsed into one profiler target;</li>
  <li>ranks across nodes received the same logical capture;</li>
  <li>every trace was verified and archived;</li>
  <li>the analysis Job consumed one identity-bound, checksummed set of files through the manifest.</li>
</ul>

<p>It is a smoke test, not a benchmark.</p>

<h2 id="closing">Closing</h2>

<p>What I took from MAIProf was the idea that profiling should be treated as a cross-host system, not a hidden copy of an unpublished internal implementation.</p>

<p>The public pieces solve capture, daemon communication, and analysis. They do not answer the harder Kubernetes questions for you: who discovers the real training processes, who owns one capture, whether state survives failure, when a changing Pod set becomes fixed, and what evidence is strong enough to say a trace has actually been delivered.</p>

<p>The first version put those answers into one central Operator. The second version gave capture ownership back to the job, then let the node agent guard node-local facts and the manifest guard delivery facts.</p>

<p>It is not a line-by-line port of MAIProf. It is a different set of choices made against the same underlying problem on a different infrastructure substrate.</p>

<blockquote>
  <p>The hard part of on-demand profiling is not pressing the shutter. It is making every photo taken across different nodes prove that it belongs to the same shot.</p>
</blockquote>]]></content><author><name>Zhenyu Sha</name></author><category term="ml-infrastructure" /><category term="kubernetes" /><category term="distributed-systems" /><category term="pytorch" /><category term="kineto" /><category term="dynolog" /><category term="hta" /><category term="kubernetes" /><category term="gpu-profiling" /><category term="distributed-training" /><summary type="html"><![CDATA[Starting from the ideas behind Meta’s MAIProf, I redesigned capture ownership, process discovery, and trace delivery for Kubernetes.]]></summary></entry><entry><title type="html">Starting from the GPU Roofline: Tuning the Data Path for Multimodal Video Training</title><link href="https://zhenyu.github.io/2026/06/29/starting-from-the-gpu-roofline-tuning-the-data-path-for-multimodal-video-training-dataloader/" rel="alternate" type="text/html" title="Starting from the GPU Roofline: Tuning the Data Path for Multimodal Video Training" /><published>2026-06-29T00:00:00+00:00</published><updated>2026-06-29T00:00:00+00:00</updated><id>https://zhenyu.github.io/2026/06/29/starting-from-the-gpu-roofline-tuning-the-data-path-for-multimodal-video-training-dataloader</id><content type="html" xml:base="https://zhenyu.github.io/2026/06/29/starting-from-the-gpu-roofline-tuning-the-data-path-for-multimodal-video-training-dataloader/"><![CDATA[<p>In a <a href="https://zhenyu.github.io/2026/05/15/large-scale-multimodal-training-data-pipelines/">previous post</a>, I wrote about design patterns for large-scale multimodal training data pipelines: metadata + blobs, distributed streaming DAGs, and pre-sharded training artifacts.</p>

<p>The main difference between these patterns is not the tool. It is <strong>which work should remain dynamic, and which work should be materialized into a training-time artifact</strong>.</p>

<p>This post is a concrete case study from that design space.</p>

<p>It is not a Ray Data tutorial, and it is not a checklist of parameters I tuned. The real question is more basic: when GPU utilization is low, should we scale the data path, change the data layout, or stop optimizing the dataloader entirely?</p>

<p>My conclusion is simple:</p>

<blockquote>
  <p>The goal of data pipeline tuning is not to make the loader infinitely fast. It is to make the GPU stop waiting for data under the largest per-device batch or micro-batch allowed by the current training recipe.</p>
</blockquote>

<p>The observations in this post come from a specific video training workload, with details abstracted where needed. The discussion focuses on a CPU decode + Ray Data streaming path. It does not cover GPU video decoding, model architecture changes, or training algorithm changes. The important part is the bottleneck reasoning process, not the absolute throughput number.</p>

<hr />

<h2 id="0-the-one-line-model">0. The one-line model</h2>

<p>My mental model is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>training throughput = min(GPU compute / memory roofline, data supply roofline)
</code></pre></div></div>

<p>This is not the strict FLOPs-vs-memory-bandwidth roofline model. I am using roofline here as an engineering mental model: training throughput is bounded by both the GPU-side compute/memory ceiling and the data-supply ceiling. The lower one determines actual throughput.</p>

<p>So before tuning the dataloader, do not touch the dataloader.</p>

<p>The first step is to remove loading from the equation. Use a small controlled dataset, a controlled read path, or even fake batches if needed. Ask the GPU-side question first:</p>

<ul>
  <li>As batch size increases, does compute start to dominate?</li>
  <li>Does compute become the first bottleneck, or does memory hit the limit first?</li>
  <li>If memory hits the limit first, what is the largest per-device batch or micro-batch allowed by the training recipe?</li>
</ul>

<p>Only after the GPU-side workload is fixed does <code class="language-plaintext highlighter-rouge">data_wait</code> become meaningful. At that point, the question is:</p>

<blockquote>
  <p>Under the largest per-device batch or micro-batch allowed by this recipe, is the GPU worker still waiting for the next batch?</p>
</blockquote>

<p>That was the tuning order in this case: <strong>first establish the GPU roofline, then raise the data roofline</strong>.</p>

<p>Ray Data was useful because it turned the data-supply side into an independently scalable distributed pipeline. ETL was useful because it moved repeated per-epoch work across the materialization boundary.</p>

<hr />

<h2 id="1-find-the-gpu-side-ceiling-before-tuning-the-loader">1. Find the GPU-side ceiling before tuning the loader</h2>

<p>A lot of data loading work starts with low GPU utilization and immediately jumps to adding workers, increasing prefetch, or changing storage.</p>

<p>I think that order is risky.</p>

<p>Low GPU utilization can mean at least three different things:</p>

<ul>
  <li>the batch is too small, so the GPU is not being exercised;</li>
  <li>the batch cannot be increased because memory hits the limit first;</li>
  <li>the data pipeline really is too slow, and the GPU is waiting for batches.</li>
</ul>

<p>These have completely different fixes.</p>

<p>The first case needs a larger batch or heavier model-side work.<br />
The second needs more memory, model changes, activation/memory optimization, or a different batch strategy.<br />
Only the third is primarily a data pipeline problem.</p>

<p>So the first experiment I ran was a “remove the data variable” experiment. I used a small dataset, controlled the read path, and used fake batches where useful. The goal was not to measure final throughput. The goal was to establish the GPU-side load curve: as batch size increases, does forward/backward become the main component, and when does memory hit the wall?</p>

<p>The fake batch needs to preserve the real batch shape, dtype, device transfer path, and model input structure as much as possible. Otherwise it only measures pure model compute, not the full trainer step.</p>

<p>This baseline pins down the optimization target:</p>

<blockquote>
  <p>Reduce GPU wait under the largest per-device batch allowed by the current training recipe.</p>
</blockquote>

<p>Without this baseline, <code class="language-plaintext highlighter-rouge">data_wait</code> is hard to interpret. A small batch can amplify data wait even when the data pipeline is not fundamentally bad. A batch that is capped by GPU memory should not be “fixed” by adding more CPU decode capacity.</p>

<hr />

<h2 id="2-data_wait-measures-gpu-facing-wait-not-total-data-pipeline-cost">2. <code class="language-plaintext highlighter-rouge">data_wait</code> measures GPU-facing wait, not total data pipeline cost</h2>

<p>The profile itself is not the main point of the post. It is only a tool for answering the roofline question: is the trainer computing, or is it waiting?</p>

<p>The <code class="language-plaintext highlighter-rouge">data_wait</code> I care about is <strong>GPU-facing wait</strong>: after one training step finishes, how long does the worker block before the next batch is actually handed to the trainer?</p>

<p>Ray Data may still be reading from S3, decoding, collating, transferring, and prefetching in the background. If that work is overlapped with GPU compute, then it is not GPU wait.</p>

<p>This distinction matters. I am not trying to prove that the data pipeline has no cost. I am asking a narrower question:</p>

<blockquote>
  <p>Under the current maximum per-device batch or micro-batch, is the data side still making the GPU sit idle?</p>
</blockquote>

<p>GPU utilization sampling is only supporting evidence. For short jobs, there may be too few samples. GPU sampling also sees only the GPU process. It does not see the CPU decode pool, the Ray object store, or operator backpressure.</p>

<p>For full supply-side observability, the pipeline needs cluster-level metrics such as Ray/Prometheus metrics. But for answering whether the GPU is blocked by data, worker-side <code class="language-plaintext highlighter-rouge">data_wait</code> is more direct.</p>

<p>There is one more caveat: <code class="language-plaintext highlighter-rouge">data_wait</code> answers a critical-path wait question, not the full end-to-end throughput story. Decode or collate cost may be hidden during steady-state steps by prefetch, while startup, tail latency, object store spilling, repartitioning, or shuffle can still stretch epoch wall-clock. Per-step profile and overall wall-clock need to be read together.</p>

<p>Also, CUDA kernels are submitted asynchronously. Without fixed synchronization points, some GPU work may be delayed into iterator boundaries, H2D copy, or the next step. So this profile is best used to determine whether GPU-facing wait is on the critical path, not to assign perfect absolute attribution to every substage. For that, a CUDA timeline or PyTorch profiler is needed.</p>

<hr />

<h2 id="3-why-ray-data-moving-the-data-roofline-independently">3. Why Ray Data: moving the data roofline independently</h2>

<p>The training sample in this pipeline looked roughly like this: multiple camera video frames, multiple forms of ground truth, and multiple task heads. The data lived in object storage. Downloading everything locally first was not a realistic answer. The video was stored in compressed form, so every epoch had to turn bitstreams back into pixels.</p>

<p>Ray Data was not useful because it magically made video decode faster. It was useful because it separated the data-supply side from the trainer-local loader:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>S3 / metadata + blobs
  -&gt; distributed read / decode / transform
  -&gt; streaming batches with backpressure
  -&gt; GPU trainer
</code></pre></div></div>

<p>This corresponds to Pattern B in my previous post: the distributed streaming DAG.</p>

<p>Its design contract is not “automatically faster than a pre-sharded loader.” Its design contract is that <strong>CPU-heavy data stages can scale separately from GPU-heavy training stages</strong>.</p>

<p>Once that separation exists, read/decode operator concurrency becomes a meaningful control. If the GPU is waiting for data, increase CPU decode concurrency. If increasing it stops helping, then the bottleneck is probably not the number of parallel slots. It may be per-sample cost, data layout, or the GPU side already reaching its ceiling.</p>

<p>That is the tuning taste I care about:</p>

<blockquote>
  <p>Identify which roofline is lower, then move the control that affects that roofline.</p>
</blockquote>

<hr />

<h2 id="4-first-data-roofline-adding-cpu-helps-but-it-is-not-the-root-fix">4. First data roofline: adding CPU helps, but it is not the root fix</h2>

<p>The original version had a very common problem: high-resolution MP4s were decoded at runtime, while the model only needed a lower training input resolution.</p>

<p>The model side already had multiple task heads, but the training workers were still spending a large amount of time waiting for batches.</p>

<p>Increasing <code class="language-plaintext highlighter-rouge">read_concurrency</code> / decode concurrency was the right first move because it validated an important fact: Ray Data could move decode out of the GPU training process and raise the supply side by using an external CPU pool. As concurrency increased, wall-clock improved and GPU wait decreased.</p>

<p>But the experiment also showed the boundary. After a point, adding more CPU had diminishing returns.</p>

<p>This is where it is important not to keep tuning random parameters. If raw S3 reads are fast but the trainer is still waiting, the problem is probably not “bytes cannot be read.” The heavy work inside the ReadTask is video decode.</p>

<p>The first decision point was:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>adding CPU helps       -&gt; the data supply roofline was indeed too low
adding CPU plateaus    -&gt; per-sample decode cost has become the lower bound
</code></pre></div></div>

<p>So Ray Data CPU scaling is a safety valve, not the final answer. It tells you where the bottleneck is, and it can buy time. But if every epoch repeatedly decodes pixels that the model will never use, the right long-term answer is not to keep buying more CPU.</p>

<hr />

<h2 id="5-the-real-leverage-moving-the-materialization-boundary">5. The real leverage: moving the materialization boundary</h2>

<p>This goes back to the core question from the previous post:</p>

<blockquote>
  <p>Which work should remain dynamic, and which work should be materialized into a training artifact?</p>
</blockquote>

<p>Runtime decode of high-resolution video followed by resize is deterministic repeated work. If the model input resolution is already fixed, that work should not remain in the training critical path.</p>

<p>The biggest optimization was not changing the framework. It was ETL: re-encode the video into training resolution when building the dataset.</p>

<p>The decode cost is then paid once, not once per epoch, per worker, per GPU.</p>

<p>The benefit was order-of-magnitude. The same content encoded at training resolution is much easier to decode than “decode the full high-resolution bitstream, then downscale.” This benefit does not depend on Ray. Any loader that repeatedly decodes high-resolution video at runtime pays this tax.</p>

<p>This is also how I think about questions like “Ray Data vs WebDataset vs MDS vs TFRecord.” Tool choice should come after boundary choice.</p>

<p>First decide which processing steps should move into a training artifact. Then choose the format and execution engine that best serve that decision.</p>

<hr />

<h2 id="6-bottleneck-migration-once-decode-drops-collate-appears">6. Bottleneck migration: once decode drops, collate appears</h2>

<p>After ETL reduced the decode cost, GPU compute rose significantly. Work that had previously been hidden under decode started to show up.</p>

<p>The next bottleneck was collate. More specifically, the ground truth still existed as JSON strings inside the episode data, and runtime collate had to parse that JSON for every batch.</p>

<p>When decode was slow, JSON parsing looked unimportant. Once decode dropped, it became a large part of the critical path.</p>

<p>This is an easy mistake to make: an early profile may show a small collate percentage, but that does not mean collate is not worth optimizing. It may simply be diluted by a larger bottleneck. Roofline tuning often works this way: only after one bottleneck is fixed does the next one become eligible to appear.</p>

<p>The fix was the same kind of boundary movement: pre-decode structured ground truth into Arrow columns during ETL. At runtime, the pipeline only needed columnar reads and reshaping, not JSON parsing for every batch.</p>

<p>I prefer this over adding an in-memory LRU cache around JSON parsing. An LRU cache hides repeated cost inside each worker’s Python heap. It has unclear memory semantics and a hit-rate problem that now needs to be managed. Arrow columns turn the data into a training-time structure, reduce runtime JSON parsing and Python object construction, and fit more naturally into Ray Data’s columnar block processing.</p>

<hr />

<h2 id="7-decoder-optimization-useful-but-only-after-reducing-the-work">7. Decoder optimization: useful, but only after reducing the work</h2>

<p>Switching to a decoder such as Decord, which is better suited for video batch/random access, also helped. Its <code class="language-plaintext highlighter-rouge">get_batch(indices)</code> interface is useful for temporal window sampling.</p>

<p>This matters because a sample often needs not just one frame, but a sequence of <code class="language-plaintext highlighter-rouge">L</code> frames around a timestamp. Although compressed video random access is still constrained by keyframes and GOP structure, a batch/random-access decoder can avoid a lot of unnecessary frame-by-frame walking compared with a naive sequential path.</p>

<p>But I would still put this after ETL in the tuning story.</p>

<p>Decoder optimization is often a few-fold improvement. Moving high-resolution runtime decode out of the training path can be an order-of-magnitude improvement.</p>

<p>The tuning rule is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>first reduce the work that must be done,
then optimize the implementation of the remaining work
</code></pre></div></div>

<p>If the system is still decoding the full high-resolution bitstream at runtime, changing the decoder helps, but it is optimizing an operation that should not have been repeated in the first place.</p>

<hr />

<h2 id="8-after-fixing-per-device-batch-check-whether-the-data-side-is-still-worth-tuning">8. After fixing per-device batch, check whether the data side is still worth tuning</h2>

<p>Now we return to the GPU roofline.</p>

<p>After ETL, materializing ground truth into Arrow columns, and switching the decoder, I checked GPU wait again under the largest per-device batch allowed by the current training recipe. Then I swept CPU decode concurrency again.</p>

<p>The result was clear: a small amount of decode concurrency was already enough to feed the GPU. Adding more CPU had little marginal benefit.</p>

<p>That is the point where I consider the data-side question closed. Not because <code class="language-plaintext highlighter-rouge">data_wait</code> became exactly zero, but because:</p>

<ul>
  <li>per-device batch / micro-batch was fixed by memory or by the training recipe;</li>
  <li>GPU compute had become the dominant component;</li>
  <li>adding data-side CPU no longer significantly reduced GPU wait;</li>
  <li>the remaining wait looked more like iterator/prefetch boundaries, framework synchronization, or profiling synchronization points than decode work that could be solved by more parallelism.</li>
</ul>

<p>At this point, continuing to tune the dataloader is no longer the highest-leverage work.</p>

<p>To improve overall throughput further, the next questions should move to larger GPU memory, a better per-device batch / gradient accumulation strategy, more GPU workers, or more detailed tracing inside the training loop and DDP synchronization.</p>

<hr />

<h2 id="9-decision-rules-from-this-tuning-pass">9. Decision rules from this tuning pass</h2>

<p>The reusable part of this work is not a specific percentage. It is the decision order.</p>

<table>
  <thead>
    <tr>
      <th>Observation</th>
      <th>Interpretation</th>
      <th>Next step</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Per-device batch can still increase under small/fake data</td>
      <td>GPU side is not fixed yet</td>
      <td>First find the compute/memory roofline</td>
    </tr>
    <tr>
      <td>Increasing per-device batch hits OOM</td>
      <td>Memory is the GPU-side constraint</td>
      <td>Lock in the largest recipe-allowed per-device batch, then inspect data wait</td>
    </tr>
    <tr>
      <td>GPU waits for batches under fixed per-device batch</td>
      <td>Data supply roofline is too low</td>
      <td>Increase <code class="language-plaintext highlighter-rouge">read_concurrency</code> / decode concurrency</td>
    </tr>
    <tr>
      <td>Adding CPU helps but quickly plateaus</td>
      <td>Per-sample cost is too high</td>
      <td>Change layout / ETL instead of adding more CPU</td>
    </tr>
    <tr>
      <td>Collate rises after decode drops</td>
      <td>Bottleneck migration</td>
      <td>Move repeated parse / transform work into ETL</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">data_wait</code> is low and CPU sweep no longer helps</td>
      <td>Data side is no longer the main bottleneck</td>
      <td>Stop tuning the loader; move to GPU / DDP / memory work</td>
    </tr>
  </tbody>
</table>

<p>The same decision order explains the trade-off between eager ETL and runtime flexibility:</p>

<table>
  <thead>
    <tr>
      <th>Path</th>
      <th>Advantages</th>
      <th>Cost</th>
      <th>Best fit</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>eager ETL</td>
      <td>Low per-sample cost; a small CPU pool can feed GPUs</td>
      <td>Changes require rematerialization</td>
      <td>Production training; stable training view</td>
    </tr>
    <tr>
      <td>runtime flexible</td>
      <td>Easy to change sampling, resolution, or view logic</td>
      <td>Higher CPU and object-store pressure</td>
      <td>Research exploration; rapidly evolving data view</td>
    </tr>
  </tbody>
</table>

<p>This is not a binary choice. A mature system often needs both: keep more dynamicity early, then move repeated work forward once the view stabilizes.</p>

<hr />

<h2 id="10-the-order-of-controls-in-this-pipeline">10. The order of controls in this pipeline</h2>

<p>If I only list Ray Data API parameters, this becomes a checklist. My ordering is based on the roofline reasoning instead:</p>

<table>
  <thead>
    <tr>
      <th>Control</th>
      <th>What it changes</th>
      <th>When to use it</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>per-device batch / micro-batch</td>
      <td>GPU-side load</td>
      <td>Set this first to find the compute/memory roofline</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">read_concurrency</code> / decode concurrency</td>
      <td>CPU decode parallelism</td>
      <td>Use after fixing per-device batch, when GPU wait is high</td>
    </tr>
    <tr>
      <td>ETL to training resolution</td>
      <td>Per-sample decode cost</td>
      <td>Use when CPU scaling plateaus and decode cost is the lower bound</td>
    </tr>
    <tr>
      <td>ETL ground truth into Arrow columns</td>
      <td>Runtime parse cost</td>
      <td>Use when collate appears after decode drops</td>
    </tr>
    <tr>
      <td>Decoder replacement</td>
      <td>Implementation cost of necessary decode</td>
      <td>Use for random access / L-frame windows or slow decode implementation</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">prefetch_batches</code></td>
      <td>Overlap between data work and GPU compute</td>
      <td>Use when worker wait mainly comes from boundary gaps</td>
    </tr>
    <tr>
      <td>block/file-level shuffle + local shuffle buffer</td>
      <td>Shuffle quality under streaming constraints</td>
      <td>Use to avoid full materialization and keep bounded buffers</td>
    </tr>
  </tbody>
</table>

<p>Shuffle is especially easy to misunderstand.</p>

<p>Ray Data streaming execution does not mean “materialize the whole dataset, then start training.” For large datasets, a shuffle strategy that requires full materialization can blow up object store pressure. A more practical approach is block/file-level reordering plus a local reservoir buffer during iteration.</p>

<p>This is not equivalent to a strict global random permutation, but for very large streaming training workloads, it is often a better trade-off between randomness, throughput, and resource cost.</p>

<hr />

<h2 id="11-connecting-back-to-the-previous-post">11. Connecting back to the previous post</h2>

<p>In the previous post, I argued that the core question in a multimodal training data pipeline is the materialization boundary:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Which stages remain dynamic, and which stages become stable training artifacts?
</code></pre></div></div>

<p>This case study made that framing feel even more useful.</p>

<p>Ray Data is a good fit for a dynamic distributed streaming DAG. It can move CPU-heavy read/decode/transform work out of the GPU trainer and scale it independently. But when a transform becomes stable and repeats every epoch, it should be moved forward.</p>

<p>So the conclusion is not “Ray Data wins” or “ETL wins.” A better summary is:</p>

<blockquote>
  <p>Ray Data gave me a control knob for moving the data-supply roofline. The roofline profile told me when to keep scaling and when to move the materialization boundary.</p>
</blockquote>

<p>That is the interesting part of this kind of tuning. Good system tuning is not about turning every knob. It is about knowing which knob corresponds to the current bottleneck.</p>

<p>The best final state is not that the dataloader becomes infinitely fast. It is that I no longer need to keep turning the data-side CPU knob. The data path has moved back to where it should be. The remaining problems belong to the training side itself: GPU memory, compute, batch strategy, and distributed training synchronization.</p>]]></content><author><name>Zhenyu Sha</name></author><category term="ml-infrastructure" /><category term="data-pipeline" /><category term="distributed-systems" /><category term="multimodal" /><category term="video-training" /><category term="ray-data" /><category term="ray-train" /><category term="data-pipeline" /><category term="gpu-utilization" /><category term="etl" /><summary type="html"><![CDATA[In a previous post, I wrote about design patterns for large-scale multimodal training data pipelines: metadata + blobs, distributed streaming DAGs, and pre-sharded training artifacts.]]></summary></entry><entry><title type="html">How to Get Tensor Core Metrics from a SageMaker Training Job</title><link href="https://zhenyu.github.io/2026/06/05/how-to-get-tensor-core-metrics-from-sagemaker-training/" rel="alternate" type="text/html" title="How to Get Tensor Core Metrics from a SageMaker Training Job" /><published>2026-06-05T00:00:00+00:00</published><updated>2026-06-05T00:00:00+00:00</updated><id>https://zhenyu.github.io/2026/06/05/how-to-get-tensor-core-metrics-from-sagemaker-training</id><content type="html" xml:base="https://zhenyu.github.io/2026/06/05/how-to-get-tensor-core-metrics-from-sagemaker-training/"><![CDATA[<p>W&amp;B will happily tell you your GPU is at “100% utilization.” It will not tell you whether the workload actually hit the tensor-instruction path, whether the SMs were meaningfully occupied, or whether the memory subsystem was the real bottleneck.</p>

<p>Those are very different facts. A default dashboard usually shows only one of them.</p>

<p>I went looking for the second fact — Tensor Core activity, SM occupancy, DRAM bandwidth — inside a normal SageMaker training job. It turned into a satisfying little rabbit hole about Linux capabilities, two different ways NVIDIA exposes GPU data, and one narrow door that happens to be open on Hopper and newer GPUs.</p>

<p>This post is the map I wish I’d had.</p>

<h2 id="the-gap">The gap</h2>

<p><code class="language-plaintext highlighter-rouge">nvidia-smi</code> and W&amp;B’s built-in system monitor both answer “is a kernel running?” They show you <code class="language-plaintext highlighter-rouge">gpu.0.gpu</code> utilization, memory, temperature, power, and clocks. None of that tells you how full the silicon actually is.</p>

<p>“Utilization 100%” can mean your SMs are saturated doing real work. It can also mean one small kernel is keeping the GPU nominally busy while the expensive units sit mostly idle.</p>

<p>The metrics that answer “how full” are things like:</p>

<ul>
  <li><strong>SM active / SM occupancy</strong> — are the streaming multiprocessors actually working, and how packed are they?</li>
  <li><strong>Tensor pipe active</strong> — are you using the Tensor Cores at all?</li>
  <li><strong>DRAM active</strong> — are you memory-bandwidth bound?</li>
</ul>

<p>These are exactly the kind of signals tools like DCGM and Systalyze’s <code class="language-plaintext highlighter-rouge">utilyze</code> surface. So I tried to wire DCGM into a SageMaker job. It failed in a way that, once I understood it, explained everything.</p>

<h2 id="two-kinds-of-gpu-data">Two kinds of GPU data</h2>

<p>Here’s the thing that makes this confusing until it suddenly isn’t. “Profile my training” splits into two categories with different data paths and different privilege requirements:</p>

<table>
  <thead>
    <tr>
      <th>Question</th>
      <th>Where the data comes from</th>
      <th>Extra privilege in a restricted container?</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>When did each kernel run, how long, what gap between them</td>
      <td>CUPTI <strong>Activity</strong> API, your own process’s events</td>
      <td><strong>No</strong></td>
    </tr>
    <tr>
      <td>How full were the SMs, Tensor Cores, and memory bus</td>
      <td>hardware <strong>performance counters</strong></td>
      <td><strong>Yes</strong> — typically admin counter access / <code class="language-plaintext highlighter-rouge">CAP_SYS_ADMIN</code></td>
    </tr>
  </tbody>
</table>

<p>This is why PyTorch Profiler’s default timeline works almost anywhere. It is the Activity API recording your kernels’ timestamps.</p>

<p>The moment you ask for hardware-counter data — SM activity, Tensor Core activity, memory-system activity — you are touching a global, cross-process hardware resource. NVIDIA gates that behind the driver’s performance-counter permission model. In a restricted Linux container, that effectively lands on <code class="language-plaintext highlighter-rouge">CAP_SYS_ADMIN</code>.</p>

<p>The reasoning is sound. Performance counters are a side-channel risk in multi-tenant environments, and <code class="language-plaintext highlighter-rouge">CAP_SYS_ADMIN</code> is a broad, dangerous capability.</p>

<p>So the question “can I get Tensor Core metrics in SageMaker?” reduces to a very concrete one: does my SageMaker training container have the capability needed by the normal profiling path?</p>

<h2 id="the-missing-piece-sagemaker-training-containers-dont-have-cap_sys_admin">The missing piece: SageMaker training containers don’t have <code class="language-plaintext highlighter-rouge">CAP_SYS_ADMIN</code></h2>

<p>I confirmed this four independent ways, because it kept feeling like surely I was just holding it wrong.</p>

<ol>
  <li>
    <p><strong>The error.</strong> DCGM’s <code class="language-plaintext highlighter-rouge">dcgmi dmon</code> for the profiling fields returns <code class="language-plaintext highlighter-rouge">Error setting watches. Result: -29: ... requires the host engine to be running as root.</code> The message says “root,” but the real boundary is the capability / admin counter-access path.</p>
  </li>
  <li>
    <p><strong>A controlled local A/B.</strong> Same image, same container. With <code class="language-plaintext highlighter-rouge">docker run --cap-add SYS_ADMIN</code>, the SM/Tensor fields stream. Without it, the identical <code class="language-plaintext highlighter-rouge">-29</code> appears. So it is the capability, not the user id. Container “root” is not the same thing as <code class="language-plaintext highlighter-rouge">CAP_SYS_ADMIN</code>.</p>
  </li>
  <li>
    <p><strong>The API has no knob.</strong> AWS’s <code class="language-plaintext highlighter-rouge">CreateTrainingJob</code> API has no field for Linux capabilities or privileged mode. You can set the image, environment, resource config, VPC, entrypoint, debugger options, and so on. There is nowhere to ask SageMaker to add a container capability.</p>
  </li>
  <li>
    <p><strong>I measured it.</strong> I ran a probe that printed <code class="language-plaintext highlighter-rouge">/proc/self/status</code>. The container’s effective capabilities decode to Docker’s default set: <code class="language-plaintext highlighter-rouge">CHOWN</code>, <code class="language-plaintext highlighter-rouge">DAC_OVERRIDE</code>, <code class="language-plaintext highlighter-rouge">FOWNER</code>, <code class="language-plaintext highlighter-rouge">FSETID</code>, <code class="language-plaintext highlighter-rouge">KILL</code>, <code class="language-plaintext highlighter-rouge">SETGID</code>, <code class="language-plaintext highlighter-rouge">SETUID</code>, <code class="language-plaintext highlighter-rouge">NET_BIND_SERVICE</code>, <code class="language-plaintext highlighter-rouge">SYS_CHROOT</code>, and <code class="language-plaintext highlighter-rouge">AUDIT_WRITE</code>. <code class="language-plaintext highlighter-rouge">CAP_SYS_ADMIN</code> — bit 21 — is not in it. Same mask on A10G and H100.</p>
  </li>
</ol>

<p>A useful detail: in the environments I tested, this behaved like a platform-level policy, not a GPU-specific property. Even on the large exclusive instances where one job owns the physical box, the capability is still withheld. Since the capability mask was identical across the instance types I tested, the bottleneck is the way SageMaker starts the container, not the particular GPU.</p>

<p>So: in a stock SageMaker training job, DCGM and <code class="language-plaintext highlighter-rouge">utilyze</code>-style profiling counters are effectively unavailable. They require a capability that SageMaker does not expose through the training-job API.</p>

<p>One aside: AWS’s own GPU health-check sample can run <code class="language-plaintext highlighter-rouge">dcgmi diag</code> successfully on SageMaker. That briefly fooled me. But <code class="language-plaintext highlighter-rouge">diag</code> is a self-test path; it is not evidence that DCGM profiling counters are available.</p>

<h2 id="the-workaround-nvml-gpm">The workaround: NVML GPM</h2>

<p>Here’s the door that’s open.</p>

<p>NVIDIA’s NVML library has a feature called <strong>GPM</strong> — GPU Performance Monitoring: <code class="language-plaintext highlighter-rouge">nvmlGpmQueryDeviceSupport</code>, <code class="language-plaintext highlighter-rouge">nvmlGpmSampleGet</code>, and <code class="language-plaintext highlighter-rouge">nvmlGpmMetricsGet</code>.</p>

<p>GPM is not a full replacement for DCGM profiling. It is the path that matters for this particular problem: it exposes an overlapping class of GPU-efficiency signals — SM utilization, SM occupancy, tensor activity, DRAM bandwidth, FP16/FP32/FP64 utilization, PCIe throughput, NVLink throughput — through the NVML driver interface rather than the DCGM profiling-counter path.</p>

<p>And critically, in a SageMaker H100 training container with the default capability set, that NVML GPM path works without <code class="language-plaintext highlighter-rouge">CAP_SYS_ADMIN</code>.</p>

<p>There is one catch: <strong>GPM is implemented on Hopper and newer GPUs</strong>. On anything older — including A100 and A10G — <code class="language-plaintext highlighter-rouge">nvmlGpmQueryDeviceSupport</code> returns unsupported.</p>

<p>I verified both halves on actual SageMaker hardware with a tiny probe: no DCGM, no extra capability, no training loop, just NVML.</p>

<table>
  <thead>
    <tr>
      <th>Instance</th>
      <th>GPU</th>
      <th><code class="language-plaintext highlighter-rouge">isSupportedDevice</code></th>
      <th>Sample without <code class="language-plaintext highlighter-rouge">CAP_SYS_ADMIN</code>?</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ml.g5.xlarge</code></td>
      <td>A10G, Ampere</td>
      <td><strong>False</strong></td>
      <td>unsupported</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ml.p5.48xlarge</code></td>
      <td>H100, Hopper</td>
      <td><strong>True</strong></td>
      <td><strong>Yes</strong> — sampled <code class="language-plaintext highlighter-rouge">sm_util</code> / <code class="language-plaintext highlighter-rouge">sm_occupancy</code> with the container’s default caps</td>
    </tr>
  </tbody>
</table>

<p>That second row is the punchline. The H100 container had the exact same capability set as the A10G one — no <code class="language-plaintext highlighter-rouge">CAP_SYS_ADMIN</code> — and GPM sampled fine anyway.</p>

<p>The point is not H100 by itself. The point is that Hopper and newer GPUs expose this NVML GPM path, while pre-Hopper GPUs do not.</p>

<p>So the decision tree for a SageMaker training job becomes:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Want SM / Tensor Core / DRAM utilization in a SageMaker training job?
├─ DCGM / utilyze  → need CAP_SYS_ADMIN → ✗ (not granted, can't add)
└─ NVML GPM        → accessible through the non-privileged NVML path
                     ├─ pre-Hopper (T4/A10G/A100) → unsupported → ✗
                     └─ Hopper+ (H100/H200/B200)  → ✓  ← the one open door
</code></pre></div></div>

<p>If your training runs on Hopper or newer hardware — and a lot of large pretraining does — you can have Tensor Core and SM-efficiency metrics in a stock SageMaker training job. Everywhere else on managed SageMaker training, you are mostly limited to the device-level metrics that W&amp;B and <code class="language-plaintext highlighter-rouge">nvidia-smi</code> already give you.</p>

<h2 id="putting-it-together">Putting it together</h2>

<p>The collection itself is refreshingly boring. It is pure NVML through <code class="language-plaintext highlighter-rouge">pynvml</code>: no daemon, no extra binary, no root.</p>

<p>You query whether the device supports GPM, take two samples a moment apart, and ask GPM for the metrics computed across that interval: SM utilization, SM occupancy, tensor activity, memory bandwidth utilization, and the rest. That’s the whole mechanism.</p>

<p>To make it useful for real training, run it out of band: a background thread sampling on an interval and shipping the numbers to wherever your training metrics already live. For me, that meant attaching to the same W&amp;B run as the trainer, so the GPU-efficiency curves sit next to the loss curves.</p>

<p>Two practical lessons from wiring that up:</p>

<ul>
  <li><strong>Start it after <code class="language-plaintext highlighter-rouge">wandb.init()</code></strong>, inside the training process, so you can attach to the live run. A child process can’t share that handle; a same-process background thread can.</li>
  <li><strong>Fail-isolate it.</strong> A metrics collector must never slow down or crash the actual training. Sampling errors get logged and dropped, never raised.</li>
</ul>

<h2 id="takeaways">Takeaways</h2>

<ul>
  <li>“Profile my GPU” is two questions with two privilege levels. <strong>Timeline = CUPTI Activity = no extra privilege. Efficiency/utilization counters = hardware-counter path = admin counter access / <code class="language-plaintext highlighter-rouge">CAP_SYS_ADMIN</code> in restricted containers.</strong> Know which one you’re asking for.</li>
  <li><strong>Managed SageMaker training does not grant <code class="language-plaintext highlighter-rouge">CAP_SYS_ADMIN</code>, and there is no training-job API field to add it.</strong> So the DCGM / CUPTI-counter / <code class="language-plaintext highlighter-rouge">utilyze</code> profiling-counter family is blocked in a stock SageMaker training job.</li>
  <li><strong>NVML GPM is the exception</strong>. It exposes the GPU-efficiency metrics I needed through a different, non-privileged NVML path, but only on Hopper and newer GPUs. On Hopper+, you can get Tensor Core and SM-efficiency metrics in a stock training job with nothing more than a pip dependency.</li>
  <li>The constraint is fundamentally about who controls the container’s capabilities. On infrastructure you control, you can add <code class="language-plaintext highlighter-rouge">CAP_SYS_ADMIN</code> and the normal DCGM / <code class="language-plaintext highlighter-rouge">utilyze</code> profiling surface opens up. On a managed platform, you ride whatever non-privileged door the vendor and driver stack leave open. On SageMaker plus NVIDIA Hopper, GPM is that door.</li>
</ul>]]></content><author><name>Zhenyu Sha</name></author><category term="ml-infrastructure" /><category term="gpu" /><category term="observability" /><category term="sagemaker" /><category term="gpu-profiling" /><category term="dcgm" /><category term="nvml" /><category term="gpm" /><category term="h100" /><category term="hopper" /><category term="tensor-cores" /><summary type="html"><![CDATA[W&amp;B will happily tell you your GPU is at “100% utilization.” It will not tell you whether the workload actually hit the tensor-instruction path, whether the SMs were meaningfully occupied, or whether the memory subsystem was the real bottleneck.]]></summary></entry><entry><title type="html">Is Kubernetes Autoscaling Missing a Capacity Intent Layer?</title><link href="https://zhenyu.github.io/2026/05/28/is-kubernetes-autoscaling-missing-a-capacity-intent-layer/" rel="alternate" type="text/html" title="Is Kubernetes Autoscaling Missing a Capacity Intent Layer?" /><published>2026-05-28T00:00:00+00:00</published><updated>2026-05-28T00:00:00+00:00</updated><id>https://zhenyu.github.io/2026/05/28/is-kubernetes-autoscaling-missing-a-capacity-intent-layer</id><content type="html" xml:base="https://zhenyu.github.io/2026/05/28/is-kubernetes-autoscaling-missing-a-capacity-intent-layer/"><![CDATA[<p>A friend recently made a simple observation about Kubernetes autoscaling:</p>

<blockquote>
  <p>Kubernetes can scale Pods, but turning those Pods into the right Nodes still feels like something every DevOps or platform team has to solve by hand.</p>
</blockquote>

<p>That comment stuck with me.</p>

<p>At first, it sounds like an implementation complaint. Maybe HPA is too limited. Maybe Cluster Autoscaler is too reactive. Maybe Karpenter needs more policy. Maybe every platform team just needs better conventions around NodePools, labels, taints, instance types, and reservations.</p>

<p>But after looking at the problem more carefully, I think the deeper issue is not any single autoscaler.</p>

<p>The issue is that Kubernetes does not have a standard way for a workload to express what kind of capacity it actually wants before the system falls back to Pending Pods as the main signal.</p>

<p>In other words, Kubernetes autoscaling may be missing a <strong>capacity intent layer</strong>.</p>

<p>That is the question I want to reason through in this post.</p>

<p>Over the past few years, Kubernetes autoscaling has accumulated a rich set of components and abstractions: HPA, VPA, KEDA, Cluster Autoscaler, Karpenter, Kueue, DRA, Gateway API Inference Extension, and many others. Each of them solves a real problem, and each can tell a coherent story on its own.</p>

<p>But if we connect these systems end to end, the operational pain my friend described becomes easier to understand.</p>

<p>The common autoscaling path still looks roughly like this:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HPA / workload controller / application autoscaler
  -&gt; create or patch Pods
  -&gt; scheduler writes PodScheduled=False, Reason=Unschedulable into Pod status
  -&gt; Cluster Autoscaler or Karpenter observes Pending Pods
  -&gt; node provisioning happens
  -&gt; scheduler binds Pods to newly available Nodes
</code></pre></div></div>

<p>This model works well enough for many stateless services. A web service replica is usually fungible. A new Pod can handle any request after it becomes ready. Nodes are often treated as mostly interchangeable. If the cluster does not have enough capacity, creating more Pods and letting the node autoscaler react to Pending Pods is a reasonable design.</p>

<p>However, once the workload becomes LLM inference, Ray-based streaming, GPU batch, or stateful data processing, this chain starts to expose a more fundamental limitation.</p>

<p>The issue is not that HPA is too simple, or that Karpenter is not smart enough. The issue is that the downstream layers are often forced to infer too much from a low-semantic signal.</p>

<p>A Pending Pod mainly says:</p>

<blockquote>
  <p>Under the current cluster state and this Pod’s scheduling constraints, I cannot be scheduled right now.</p>
</blockquote>

<p>It does not clearly say:</p>

<blockquote>
  <p>Why am I unschedulable? What kind of capacity do I need? How long can I wait? Can I fall back to another GPU class? Can I use Spot? Do I need warm capacity? Can I be interrupted? Is scale-down safe?</p>
</blockquote>

<p>In other words, <strong>Pending Pod is a failure-after-the-fact signal</strong>. What many AI and stateful workloads need is closer to a <strong>capacity intent declared before provisioning</strong>.</p>

<h2 id="hpas-boundary-is-not-metrics-it-is-semantics">HPA’s Boundary Is Not Metrics. It Is Semantics.</h2>

<p>HPA has a deliberately narrow model. It answers one main question:</p>

<blockquote>
  <p>Given some observed metrics, what should the replica count of this workload be?</p>
</blockquote>

<p>That abstraction was elegant in the web service era. CPU goes up, add replicas. QPS goes up, add replicas. Once new Pods become ready, a Service or ingress layer can distribute traffic to them. The scaling decision and the routing decision are relatively decoupled. Any healthy replica can usually take any request.</p>

<p>LLM inference is not like that.</p>

<p>Whether an inference replica can serve traffic is not determined merely by whether the Pod is Running. The model may still be loading. The replica may not have warmed up. KV cache locality may matter. Prefill and decode may be separated into different pools. Continuous batching may make GPU utilization look high even when the actual bottleneck is queueing, memory pressure, routing policy, or tail latency.</p>

<p>For LLM serving, the more relevant signals are often things like:</p>

<ul>
  <li>request queue depth</li>
  <li>TTFT and TPOT percentiles</li>
  <li>batch slot occupancy</li>
  <li>prefill/decode pressure</li>
  <li>KV cache locality</li>
  <li>model load state</li>
  <li>routing-level backpressure</li>
  <li>GPU memory fragmentation or headroom</li>
</ul>

<p>Of course, HPA can consume custom metrics. That is not the core problem.</p>

<p>The deeper issue is that HPA still outputs a replica count. It does not have a stable API surface to express workload properties such as:</p>

<ul>
  <li>A new replica needs three minutes of warm-up before receiving critical traffic.</li>
  <li>This model must keep a minimum amount of warm capacity.</li>
  <li>Prefill workers and decode workers have different scaling policies.</li>
  <li>A cold replica without cache locality may increase tail latency in the short term.</li>
  <li>The bottleneck is not replica count, but queueing, routing, or downstream backpressure.</li>
  <li>A newly created Pod depends on a capacity policy that goes beyond fixed Pod-template constraints, such as reservation preference, fallback, capacity type, provisioning latency, or minimum lifetime.</li>
</ul>

<p>So the limitation of HPA is not that it only looks at CPU. That is a surface-level critique. The deeper limitation is that the HPA API is not where workload capacity semantics live.</p>

<p>It is a replica-count controller, not a capacity intent interface.</p>

<h2 id="pending-pod-is-a-lossy-interface">Pending Pod Is a Lossy Interface</h2>

<p>The Kubernetes autoscaling chain is beautifully decoupled. Workload controllers create Pods. The scheduler tries to place them. Node autoscalers observe unschedulable Pods and provision more capacity. Components communicate through Kubernetes objects rather than direct RPCs.</p>

<p>That decoupling is one of Kubernetes’ strengths.</p>

<p>But the cost is semantic loss across layers.</p>

<p>By the time a node provisioning layer sees the demand, the higher-level intent has often been compressed into a set of Pod-level constraints: resource requests, node selectors, affinity, tolerations, topology spread constraints, PriorityClass, labels, annotations, and maybe a few provider-specific conventions.</p>

<p>Those fields are useful. But they are not enough to reliably reconstruct the workload’s intent.</p>

<p>From a set of Pending Pods, the provisioning layer may struggle to know:</p>

<ul>
  <li>Are these eight Pods a training gang, or eight independent online replicas?</li>
  <li>Must they run in the same zone, same rack, or same placement group?</li>
  <li>Is same-zone a hard requirement or just a preference?</li>
  <li>If H100 capacity is unavailable, can A100 be used as a fallback?</li>
  <li>Can this workload wait ten minutes for reserved capacity, or does it need on-demand capacity immediately?</li>
  <li>Can workers use Spot while the head or driver must use on-demand capacity?</li>
  <li>Should these nodes live for at least twelve hours, or can they be consolidated aggressively?</li>
  <li>Is this Pending state caused by real capacity shortage, quota exhaustion, reservation mismatch, or a bad scheduling constraint?</li>
  <li>During scale-down, which nodes are safe to drain and which would trigger expensive state reconstruction?</li>
</ul>

<p>Some of this can be encoded today through labels, annotations, NodePools, PriorityClasses, custom CRDs, or provider-specific policies. But that is not the same thing as a stable semantic contract.</p>

<p>The downstream system should not have to guess.</p>

<p>A Pending Pod is a low-semantic-density signal centered around scheduling failure. It is good at telling the system, “Something does not fit.” It is much weaker at saying, “This is the kind of future capacity this workload is trying to acquire.”</p>

<h2 id="inference-exposes-the-scale-up-problem">Inference Exposes the Scale-Up Problem</h2>

<p>LLM inference makes the scale-up gap especially obvious.</p>

<p>A normal stateless service often treats a replica as ready once the Pod passes readiness checks. For inference, readiness is only one part of the story. The serving stack may need to load a large model, initialize GPU memory, join a routing layer, warm up kernels, populate cache, and stabilize batching behavior.</p>

<p>Adding a replica can even make things worse in the short term if the routing layer sends traffic to a cold replica too early or if cache locality is destroyed.</p>

<p>This means there are really two layers of intent:</p>

<ol>
  <li><strong>Application-level scaling intent</strong>: routing, queueing, warm-up, readiness, cache locality, prefill/decode split, and request admission.</li>
  <li><strong>Capacity-level intent</strong>: GPU class, topology, zone, reservation, capacity type, fallback policy, startup latency, lifetime, and disruption constraints.</li>
</ol>

<p>Today these two layers are often connected indirectly. The application controller creates Pods. The scheduler fails to place some of them. The node autoscaler reacts. The cloud provider attempts provisioning. The application layer eventually learns whether the new capacity became useful.</p>

<p>That loop works, but it is imprecise and reactive.</p>

<p>For AI serving, the system often needs to know before creating arbitrary Pods:</p>

<ul>
  <li>Do I need warm standby capacity?</li>
  <li>Should I provision ahead of demand?</li>
  <li>Should I prefer reserved capacity over on-demand?</li>
  <li>Is Spot acceptable for overflow only?</li>
  <li>Can I fall back to a cheaper GPU class?</li>
  <li>Should prefill and decode pools be provisioned differently?</li>
  <li>What is the maximum tolerable provisioning latency?</li>
</ul>

<p>These are not simply routing decisions. They eventually affect cloud capacity provisioning. But they are also not naturally expressible as plain Pod scheduling constraints.</p>

<p>This is exactly the space where a capacity intent layer would help.</p>

<h2 id="streaming-workloads-expose-the-scale-down-problem">Streaming Workloads Expose the Scale-Down Problem</h2>

<p>Many autoscaling discussions implicitly treat scale-up and scale-down as symmetric:</p>

<blockquote>
  <p>Load goes up, add capacity. Load goes down, remove capacity.</p>
</blockquote>

<p>For streaming and stateful workloads, that symmetry is false.</p>

<p>Scale-up usually adds helpers. Scale-down removes an execution unit that may hold state, own actors, buffer data, maintain lineage, or participate in an ongoing pipeline. Removing it at the wrong time can trigger reconstruction, spilling, actor restart, backpressure, or end-to-end throughput instability.</p>

<p>Ray-based workloads are a useful example. Application-level actor scaling can understand pipeline bottlenecks because the application control plane knows which actors it created, what they are doing, and whether they are safe to remove. But at the cluster level, node autoscaling typically sees much lower-level signals: resource requests, utilization, idleness, Pod disruption budgets, taints, and node-level constraints.</p>

<p>Ray Data makes this distinction especially concrete. Its application-level execution can scale actor pools based on pipeline pressure and operator bottlenecks, because that logic lives close to the data execution graph. But cluster-level scale-down is necessarily more conservative. A worker node should only be reclaimed after the application has stopped using it and the node becomes idle. In other words, Ray Data can be relatively proactive about application-level scale-up, while cluster-level scale-down is closer to passive reclamation after the workload has made capacity safe to remove.</p>

<p>That distinction is the important part. Safe scale-down is not just a utilization problem. It is an application-state problem.</p>

<p>A node provisioning layer usually cannot know:</p>

<ul>
  <li>whether a worker currently holds important actor state</li>
  <li>whether deleting a Pod will trigger expensive object reconstruction</li>
  <li>whether low CPU means idle or blocked by downstream backpressure</li>
  <li>whether a streaming pipeline is in a safe drain point</li>
  <li>whether a node is safe to consolidate now or should be preserved</li>
</ul>

<p>For these workloads, the intent is not merely min/max replicas. The workload needs to express properties like:</p>

<ul>
  <li>This worker group may scale up automatically, but scale-down must be application-drain-only.</li>
  <li>These nodes may be consolidated; those nodes must not be consolidated.</li>
  <li>These Pods are on the critical path and should not be randomly disrupted by infrastructure-level optimization.</li>
  <li>Spot is acceptable for buffer capacity, but not for baseline capacity.</li>
  <li>Drain must complete before scale-down; if the drain timeout fails, keep the node.</li>
</ul>

<p>These semantics do not belong entirely to HPA. They also do not belong entirely to kube-scheduler. They need to flow across the boundary between workload control and node provisioning.</p>

<h2 id="dra-points-in-the-right-direction">DRA Points in the Right Direction</h2>

<p>Dynamic Resource Allocation is not a node autoscaler. It solves a different problem: how workloads declare, select, allocate, and prepare devices that exist in the cluster.</p>

<p>But DRA is interesting beyond GPUs or device plugins. It points toward a more explicit resource model.</p>

<p>Instead of reducing device demand to something like:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">resources</span><span class="pi">:</span>
  <span class="na">limits</span><span class="pi">:</span>
    <span class="na">nvidia.com/gpu</span><span class="pi">:</span> <span class="m">1</span>
</code></pre></div></div>

<p>DRA introduces a richer model. DeviceClass describes categories of devices. ResourceClaim expresses a workload’s resource request. ResourceSlice represents available device inventory. The scheduler can reason about claims and constraints. The driver participates in preparing and unpreparing resources on the node. Status records the allocation result.</p>

<p>The important shift is this:</p>

<p><strong>A resource is no longer just an integer. It becomes an object with class, attributes, constraints, allocation result, and lifecycle.</strong></p>

<p>Node provisioning faces a similar problem, but at a harder layer.</p>

<p>DRA mostly operates over devices and nodes that already exist. Node autoscaling often deals with cloud capacity that does not exist yet. The candidate space is much larger:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>instance type
x zone
x capacity type
x reservation
x quota
x price
x startup latency
x topology / placement constraints
</code></pre></div></div>

<p>Placement group is not quite the same type of dimension as instance type or zone. It is more like a supply-side topology constraint. Quota is not inventory. Reservation is not ordinary capacity. Spot has interruption risk. Capacity block has time boundaries. Startup latency matters. Price changes the optimization objective.</p>

<p>Cloud providers also do not expose complete real-time inventory to Kubernetes. A failed CreateFleet or RunInstances call is not an exceptional corner case; it is often part of the solving process. Fallback, reservation selection, quota, placement, capacity type, and startup latency are all provider-side concerns.</p>

<p>So node autoscaling should not copy DRA directly.</p>

<p>But it can borrow DRA’s schema philosophy:</p>

<ul>
  <li>claim-based requests</li>
  <li>class-based policy</li>
  <li>explicit constraints</li>
  <li>provider-specific solving</li>
  <li>allocation status</li>
  <li>lifecycle-aware cleanup</li>
  <li>understandable failure reasons</li>
</ul>

<p>DRA does not solve cloud capacity provisioning. But it shows why richer resource semantics matter once resources stop being fungible integers.</p>

<h2 id="the-missing-layer-capacity-intent">The Missing Layer: Capacity Intent</h2>

<p>If I had to give the missing piece a name, I would call it <strong>Capacity Intent</strong>, not <strong>Autoscale Policy</strong>.</p>

<p>An autoscale policy usually says:</p>

<blockquote>
  <p>Under these metrics, change the replica count this way.</p>
</blockquote>

<p>Capacity intent says something different:</p>

<blockquote>
  <p>This workload needs a certain kind of capacity, and here is how that capacity may be supplied, substituted, reserved, disrupted, and eventually released.</p>
</blockquote>

<p>A hypothetical object might look like this:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">capacity.k8s.io/v1alpha1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">CapacityClaim</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">llama-decode-capacity</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">workloadType</span><span class="pi">:</span> <span class="s">inference</span>

  <span class="na">podSets</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">decode-workers</span>
      <span class="na">count</span><span class="pi">:</span> <span class="m">8</span>
      <span class="na">templateRef</span><span class="pi">:</span> <span class="s">decode-worker-template</span>

  <span class="na">latency</span><span class="pi">:</span>
    <span class="na">maxProvisioningLatency</span><span class="pi">:</span> <span class="s">5m</span>
    <span class="na">warmCapacityRequired</span><span class="pi">:</span> <span class="no">true</span>

  <span class="na">topology</span><span class="pi">:</span>
    <span class="na">locality</span><span class="pi">:</span> <span class="s">same-zone</span>
    <span class="na">placementGroup</span><span class="pi">:</span> <span class="s">preferred</span>

  <span class="na">capacityPolicy</span><span class="pi">:</span>
    <span class="na">capacityTypes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">reserved</span>
      <span class="pi">-</span> <span class="s">on-demand</span>
    <span class="na">spotAllowed</span><span class="pi">:</span> <span class="no">false</span>
    <span class="na">fallback</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">gpuClass</span><span class="pi">:</span> <span class="s">h100</span>
      <span class="pi">-</span> <span class="na">gpuClass</span><span class="pi">:</span> <span class="s">a100</span>

  <span class="na">disruption</span><span class="pi">:</span>
    <span class="na">minLifetime</span><span class="pi">:</span> <span class="s">12h</span>
    <span class="na">consolidationAllowed</span><span class="pi">:</span> <span class="no">false</span>
    <span class="na">scaleDownPolicy</span><span class="pi">:</span> <span class="s">application-drain-only</span>
</code></pre></div></div>

<p>This object does not have to be called <code class="language-plaintext highlighter-rouge">CapacityClaim</code>. It may not even need to become a Kubernetes core API. The exact API shape is less important than the missing semantic contract.</p>

<p>The contract should allow the workload to express:</p>

<ul>
  <li>what type of capacity it needs</li>
  <li>whether the capacity must be warm</li>
  <li>how long provisioning may take</li>
  <li>what topology constraints matter</li>
  <li>which capacity types are acceptable</li>
  <li>whether fallback is allowed</li>
  <li>whether the workload is interruptible</li>
  <li>how scale-down must be coordinated</li>
  <li>how long the capacity should live</li>
  <li>what failure reasons should be reported back</li>
</ul>

<p>With this layer, the flow becomes different:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Workload declares capacity intent
  -&gt; admission checks quota, fairness, and policy
  -&gt; provisioning layer solves for node capacity
  -&gt; cloud provider attempts allocation
  -&gt; status reports selected capacity or failure reason
  -&gt; scheduler binds Pods to nodes that satisfy the intent
</code></pre></div></div>

<p>The key change is that capacity is declared before the system relies on scheduling failure as the primary signal.</p>

<p>That is a very different model from:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Create Pods first
  -&gt; let them fail scheduling
  -&gt; infer demand from failure
  -&gt; provision nodes
</code></pre></div></div>

<p>For simple services, the current model is fine. For AI and stateful workloads, it often forces too much information to be reconstructed too late.</p>

<h2 id="provisioningrequest-is-already-pointing-in-this-direction">ProvisioningRequest Is Already Pointing in This Direction</h2>

<p>There are already signs that the Kubernetes ecosystem is moving toward this idea.</p>

<p>Cluster Autoscaler’s ProvisioningRequest, and Kueue’s integration with it through AdmissionCheck, is one example. The important idea is not that ProvisioningRequest solves every workload category. It does not.</p>

<p>The important idea is that capacity availability can be checked before workload admission instead of being discovered only after Pods become Pending.</p>

<p>That matters.</p>

<p>For batch workloads, this is a natural fit. A job may need a whole group of Pods admitted together. It may not make sense to create all Pods, let them sit Pending, and then hope the autoscaler guesses the group-level intent correctly. By moving capacity checks into admission, the system can reason about the workload as a unit.</p>

<p>But this is still not the full capacity intent layer for inference, streaming, or stateful AI workloads.</p>

<p>It does not fully express:</p>

<ul>
  <li>warm capacity</li>
  <li>pre-provisioning</li>
  <li>fallback between GPU classes</li>
  <li>reservation lifecycle</li>
  <li>Spot as overflow capacity only</li>
  <li>application-drain-only scale-down</li>
  <li>cache locality</li>
  <li>long-running cluster semantics</li>
  <li>workload-specific disruption constraints</li>
</ul>

<p>There is also an important caveat: the practical value of this mechanism depends on the autoscaler and cloud provider implementation behind it. Moving the request earlier in the admission path is useful, but the API alone does not guarantee that the provider has enough inventory visibility, supports the relevant capacity classes, can reason about reservations, or can return rich fallback and failure status.</p>

<p>So ProvisioningRequest is best understood as an important signal in the right direction: capacity should become explicit earlier in the control loop.</p>

<p>It is a bridge toward capacity intent, not proof that the full interface already exists.</p>

<h2 id="cloud-providers-need-to-participate-in-the-solving-loop">Cloud Providers Need to Participate in the Solving Loop</h2>

<p>A common platform instinct is to solve everything inside Kubernetes.</p>

<p>For node provisioning, that instinct has limits.</p>

<p>Many critical facts only exist on the cloud provider side:</p>

<ul>
  <li>which zone currently has a certain GPU instance type</li>
  <li>whether a reservation can satisfy the request</li>
  <li>whether a capacity block is usable</li>
  <li>whether a placement group can still fit the requested nodes</li>
  <li>whether account quota is sufficient</li>
  <li>whether Spot is available and at what risk profile</li>
  <li>how long a certain instance family usually takes to start</li>
  <li>whether fallback to another instance type changes cost and performance too much</li>
</ul>

<p>Kubernetes should not pretend to have perfect global inventory.</p>

<p>A more realistic role for Kubernetes is to standardize the upper-layer intent, then delegate provider-specific solving to the provisioning implementation. The provider integration can solve within NodePool constraints and translate intent into instance types, zones, capacity types, reservations, NodeClaims, or cloud-specific allocation requests.</p>

<p>Then it should report back through status conditions:</p>

<ul>
  <li>capacity unavailable</li>
  <li>quota exceeded</li>
  <li>reservation mismatch</li>
  <li>fallback selected</li>
  <li>provisioning in progress</li>
  <li>partially fulfilled</li>
  <li>expired</li>
  <li>interrupted</li>
  <li>drain required</li>
  <li>consolidation blocked</li>
</ul>

<p>This feedback loop is as important as the initial request.</p>

<p>Without explicit status, the workload layer only sees that Pods are Pending, nodes are missing, or replicas are not useful yet. That is too opaque for complex AI systems.</p>

<p>Karpenter already moves beyond traditional static node groups by dynamically selecting instance types and creating capacity within the boundaries of NodePools and provider-specific NodeClasses. That is a major improvement over older node group-centric models.</p>

<p>But Karpenter still primarily reasons from Pods and their scheduling constraints. The next step is not merely to make node autoscalers more clever at reading Pending Pods. The next step is to give them better intent to work with.</p>

<h2 id="this-is-not-a-universal-autoscaler">This Is Not a Universal Autoscaler</h2>

<p>There is an easy trap here.</p>

<p>Once we say HPA is not enough and Pending Pod is too lossy, it is tempting to propose a universal autoscaler that manages online services, LLM inference, training, batch, streaming, and stateful data processing through one giant control plane.</p>

<p>I do not think that is the right answer.</p>

<p>These workloads have different scaling semantics.</p>

<p>For ordinary online services, scaling usually means adding fungible replicas.</p>

<p>For LLM inference, scaling is a joint problem across routing, queueing, batching, GPU memory, cache locality, model loading, warm capacity, and capacity provisioning.</p>

<p>For distributed training, the main problem is often not autoscaling at all. It is admission, gang scheduling, topology, failure recovery, checkpointing, quota, and preemption.</p>

<p>For streaming, scale-up and scale-down are not symmetric. Scale-down has to respect application state, drain semantics, and pipeline stability.</p>

<p>Trying to hide all of that behind one universal autoscaler would likely create another overloaded abstraction.</p>

<p>A better direction is to let each workload keep its own control plane:</p>

<ul>
  <li>HPA can continue to serve ordinary replica scaling.</li>
  <li>KEDA can continue to connect event sources to replica scaling.</li>
  <li>Kueue can continue to handle batch admission and quota.</li>
  <li>DRA can continue to express device allocation.</li>
  <li>Karpenter and cloud-provider integrations can continue to handle node provisioning.</li>
  <li>Inference gateways and serving controllers can continue to own request routing, warm-up, and traffic admission.</li>
  <li>Ray, Spark, Flink, and similar systems can continue to manage their own application-level execution semantics.</li>
</ul>

<p>The missing piece is not one controller to replace all of them.</p>

<p>The missing piece is a clearer contract between them.</p>

<p>That contract should let the workload say:</p>

<blockquote>
  <p>Here is the kind of capacity I need, the constraints under which it is useful, and the lifecycle rules under which it may be changed.</p>
</blockquote>

<p>And it should let the provisioning layer say:</p>

<blockquote>
  <p>Here is what I can allocate, what fallback I selected, why I failed, and what disruption guarantees I can or cannot provide.</p>
</blockquote>

<p>That is what I mean by a capacity intent layer.</p>

<h2 id="why-this-matters-more-for-ai-infrastructure">Why This Matters More for AI Infrastructure</h2>

<p>This problem existed before AI workloads became popular. Stateful services, distributed data processing, and batch systems have always stretched the Kubernetes scheduling model.</p>

<p>But AI infrastructure makes the issue much more visible.</p>

<p>First, the resources are less fungible. An H100 is not just a bigger CPU. GPU class, memory size, interconnect, topology, MIG configuration, driver version, and placement all matter.</p>

<p>Second, startup cost is higher. Loading a large model, warming kernels, joining a distributed runtime, or restoring state can take meaningful time.</p>

<p>Third, routing and capacity are tightly coupled. A new inference replica is not automatically useful. It must be integrated into request routing, batching, cache, and admission control.</p>

<p>Fourth, cloud capacity is uncertain. GPU supply, reservations, capacity blocks, quota, and Spot availability are all part of the operational reality.</p>

<p>Fifth, disruption is expensive. Removing the wrong node may not just restart a stateless Pod. It may disrupt actors, invalidate cache, trigger object reconstruction, or destabilize a pipeline.</p>

<p>These properties do not fit cleanly into a world where node provisioning mostly learns demand from unschedulable Pods.</p>

<p>AI infrastructure needs a stronger language for capacity.</p>

<h2 id="conclusion">Conclusion</h2>

<p>This brings me back to my friend’s complaint.</p>

<p>The reason Kubernetes autoscaling often feels like it still needs a lot of hand-built DevOps logic is not simply that the autoscalers are immature or that the metrics are not good enough.</p>

<p>A deeper reason is that workload intent is lost as it crosses layers.</p>

<p>HPA sees metrics and outputs replica count.</p>

<p>The scheduler sees Pods and constraints.</p>

<p>Karpenter or Cluster Autoscaler sees Pending Pods.</p>

<p>The cloud provider sees instance provisioning requests.</p>

<p>Each layer has local facts, but there is no standard way to express the workload’s capacity properties across the whole path.</p>

<p>Of course, many fields can be encoded today in Pod templates, ResourceClaims, PriorityClasses, topology spread constraints, annotations, custom controllers, NodePools, or provider-specific CRDs. The problem is not that expression is impossible. The problem is that the expression is scattered, implicit, and often reconstructed by convention.</p>

<p>Scattered expression is not a stable interface.</p>

<p>DRA already shows one important lesson on the device side: when resources become complex, an integer request is not enough. The system needs explicit resource attributes, constraints, allocation results, and lifecycle.</p>

<p>Node autoscaling needs a similar evolution, not by copying DRA directly, but by bringing the same philosophy to cloud capacity provisioning.</p>

<p>So the next step for Kubernetes autoscaling may not be to observe Pending Pods more cleverly.</p>

<p>It may be to make the system rely on Pending Pods less.</p>

<p>For simple workloads, scheduling failure can remain a reasonable trigger.</p>

<p>For AI and stateful workloads, capacity should become an explicit intent before every platform team is forced to encode the missing semantics by hand.</p>]]></content><author><name>Zhenyu Sha</name></author><category term="ml-infrastructure" /><category term="kubernetes" /><category term="autoscaling" /><category term="kubernetes" /><category term="autoscaling" /><category term="karpenter" /><category term="kueue" /><category term="dra" /><category term="cluster-autoscaler" /><category term="ai-infrastructure" /><category term="llm-inference" /><summary type="html"><![CDATA[A friend recently made a simple observation about Kubernetes autoscaling:]]></summary></entry><entry><title type="html">When We Talk About Workflow, What Are We Actually Talking About?</title><link href="https://zhenyu.github.io/2026/05/18/when-we-talk-about-workflow/" rel="alternate" type="text/html" title="When We Talk About Workflow, What Are We Actually Talking About?" /><published>2026-05-18T17:00:00+00:00</published><updated>2026-05-18T17:00:00+00:00</updated><id>https://zhenyu.github.io/2026/05/18/when-we-talk-about-workflow</id><content type="html" xml:base="https://zhenyu.github.io/2026/05/18/when-we-talk-about-workflow/"><![CDATA[<p>I’ve been having the same conversation a lot lately. Someone says “we’re picking a workflow engine,” and the candidates on the table are Airflow, Argo, Kubeflow Pipelines, Flyte, Dify, and Google’s ADK. They are often compared as if they are alternatives to one another. They are not, at least not in the way the shared label suggests.</p>

<p>This post is my attempt to make that observation useful rather than pedantic. I want to sort these systems into a few workflow families, using representative examples rather than attempting an exhaustive product comparison. Airflow, Argo, KFP, Flyte, Dify, and ADK are useful because each makes a different execution contract visible. The point is not that these are the only workflow systems worth discussing, or that every deployment of each system behaves exactly the same way. The point is that the word <em>workflow</em> hides several very different architectural choices.</p>

<p>The two axes I find most useful are:</p>

<ol>
  <li><strong>What is the scheduling unit?</strong></li>
  <li><strong>When does the DAG become real?</strong></li>
</ol>

<p>A third question follows from those two: <strong>what kind of durability contract does the system provide?</strong></p>

<p>These questions explain more than feature checklists. A workflow step may mean a function call inside one Python process, a task picked up by a long-running worker, or a new Pod created on a Kubernetes cluster. A DAG may be a Python file parsed by a scheduler, a compiled intermediate representation, a registered entity in a control plane, a Kubernetes custom resource, or an in-process graph traversed during a request. These are not small implementation differences. They define latency, isolation, retry semantics, operational complexity, and the kinds of workloads the system naturally fits.</p>

<p>Here is the simplified map before going into details:</p>

<table>
  <thead>
    <tr>
      <th>Workflow family</th>
      <th>Representative examples</th>
      <th>Typical scheduling unit</th>
      <th>When the DAG becomes real</th>
      <th>Optimized for</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ETL workflow</td>
      <td>Airflow</td>
      <td>Airflow task executed through a configured executor / worker backend</td>
      <td>DAG file parsed by the scheduler</td>
      <td>scheduled batch reliability, dependency management, backfill</td>
    </tr>
    <tr>
      <td>Kubernetes-native workflow</td>
      <td>Argo Workflows</td>
      <td>Kubernetes Pod / Kubernetes resource controlled by a Workflow CRD</td>
      <td>Workflow CR submitted to Kubernetes</td>
      <td>isolation, retry, artifact lineage, Kubernetes-native execution</td>
    </tr>
    <tr>
      <td>ML pipeline on Kubernetes</td>
      <td>Kubeflow Pipelines, Flyte</td>
      <td>containerized task on a backend execution system</td>
      <td>compiled IR or registered workflow entity</td>
      <td>ML pipeline authoring, reproducibility, containerized execution</td>
    </tr>
    <tr>
      <td>Agent workflow</td>
      <td>Google ADK, Dify</td>
      <td>in-process function / coroutine / sub-agent / graph node</td>
      <td>request-time or graph-defined control flow</td>
      <td>latency, interactivity, streaming model output</td>
    </tr>
  </tbody>
</table>

<p>This table is intentionally rough. Real systems have plugins, executors, backends, and deployment modes that can shift details. But the table captures the design center of each family. When teams skip this layer and compare all of them under the single label “workflow,” they often end up evaluating the wrong thing.</p>

<h2 id="the-two-axes">The two axes</h2>

<h3 id="axis-1-scheduling-unit">Axis 1: scheduling unit</h3>

<p>When the engine “runs a step,” what does that mean physically?</p>

<p>Is it a function call inside the same Python process? A task picked up by a long-running worker? A new Pod created on a Kubernetes cluster? A container launched by a pipeline backend? These choices have very different latency floors, isolation boundaries, and failure modes.</p>

<p>Most workflow systems have a design center on this axis. Some have multiple executors or deployment modes, but the design center still matters because it shapes the operational model users inherit.</p>

<h3 id="axis-2-dag-plasticity">Axis 2: DAG plasticity</h3>

<p>When is the DAG “real”?</p>

<p>Is it written in a file at deploy time and parsed by a scheduler? Is it compiled at registration time into an intermediate representation? Is it submitted as a Kubernetes custom resource? Is it traversed inside a single request, possibly while an LLM decides the next action?</p>

<p>“Static at deploy time” and “dynamic per request” are not merely points on a spectrum. They imply different control-plane assumptions. A scheduler that can backfill yesterday’s failed ETL run is solving a different problem from an agent graph that streams tokens back to a user in one request.</p>

<h3 id="consequence-durability-contract">Consequence: durability contract</h3>

<p>The scheduling unit and DAG materialization point usually determine the durability contract.</p>

<p>Airflow cares about scheduled task instances, retry, backfill, and operator visibility. Argo cares about Kubernetes resources, Pod lifecycle, artifact passing, and workflow status. KFP and Flyte care about reproducible pipeline runs and containerized tasks. Agent workflows care about low-latency orchestration inside an interactive request.</p>

<p>A system can sometimes be extended beyond its design center. But if the durability contract does not match the workload, the integration tends to feel unnatural.</p>

<h2 id="the-etl-model-airflow-and-the-self-managed-worker-tradition">The ETL model: Airflow and the self-managed worker tradition</h2>

<p>Airflow is the oldest system in this discussion, and for a long time it was <em>the</em> workflow engine in many data teams: the default answer when people meant scheduled ETL, dependency management, retries, and backfills.</p>

<p>Airflow’s architecture is organized around a scheduler, task instances, metadata state, and a configured executor. The scheduler decides when a task is ready; the executor determines how the task is physically run. In a historically common production setup, <code class="language-plaintext highlighter-rouge">CeleryExecutor</code> dispatches tasks to long-running Celery workers. In other deployments, <code class="language-plaintext highlighter-rouge">LocalExecutor</code>, <code class="language-plaintext highlighter-rouge">KubernetesExecutor</code>, or hybrid executors can change the physical execution backend.</p>

<p>So the careful statement is not that Airflow can only run tasks on Airflow-owned workers. It cannot; <code class="language-plaintext highlighter-rouge">KubernetesExecutor</code> exists and launches Pods. The more useful statement is that Airflow’s design center is an <strong>Airflow task instance managed by the Airflow scheduler/executor model</strong>, rather than a Kubernetes-native Workflow custom resource.</p>

<p>DAG plasticity is also on the static side. A DAG is usually a Python file in a DAG folder; the scheduler parses it and creates task instances according to schedules and dependencies. Airflow 2.x introduced dynamic task mapping, which is useful for runtime fan-out over collections, but it does not turn Airflow into a per-request dynamic agent runtime.</p>

<p>This is not a criticism of Airflow. Airflow is well suited to what it was originally built for: scheduled batch workflows where individual tasks may take minutes or hours, reliability matters more than per-hop latency, and backfill is a first-class operational requirement.</p>

<p>The mismatch appears when Airflow is pressed into workloads with different contracts: tight ML training loops, interactive serving paths, or LLM agent orchestration where a few seconds of orchestration latency is already too much.</p>

<h2 id="the-kubernetes-native-model-from-worker-pool-to-pod">The Kubernetes-native model: from worker pool to Pod</h2>

<p>Once Kubernetes became the substrate for many ML and data platforms, a different question became natural:</p>

<blockquote>
  <p>If Kubernetes already schedules Pods with quotas, node selectors, topology, and resource constraints, why should a workflow system maintain its own worker pool inside the cluster?</p>
</blockquote>

<p>Argo Workflows is a representative answer to that question.</p>

<p>In Argo, the design center is Kubernetes-native execution. A workflow is a Kubernetes custom resource. The controller reconciles that resource. Each step is typically represented by a Pod or Kubernetes execution primitive, and <code class="language-plaintext highlighter-rouge">kube-scheduler</code> decides where it lands.</p>

<p>This shifts the workflow engine out of the placement business. Argo defines workflow structure, dependencies, retries, parameters, and artifacts; Kubernetes handles Pod placement. That distinction matters. If a platform’s real problem is GPU quota, gang scheduling, topology-aware placement, or cluster-level admission control, changing workflow engines alone is unlikely to solve it. Those concerns sit closer to the Kubernetes scheduler, resource admission, Kueue, Volcano, or platform-specific placement logic.</p>

<p>Argo’s DAG also lives outside the user container. A workflow is submitted as a custom resource. Because steps are separate Pods, artifact passing becomes the natural communication pattern. A producing step writes declared output artifacts; the workflow system stores them in a configured artifact repository; a downstream step reads them as inputs.</p>

<p>This is strong for reproducibility, retry, and isolation. It is not the same as an in-memory streaming dataflow engine like Spark or Flink. Both may draw DAGs, but the data movement contract is different.</p>

<p>That distinction explains a common platform confusion: a workflow DAG is not automatically a data-processing DAG. Argo can orchestrate steps that process data. It does not, by itself, become a high-throughput distributed data engine with shuffle, pipelined execution, and memory-aware data movement.</p>

<h2 id="pythonic-ml-pipelines-on-top-of-workflow-backends">Pythonic ML pipelines on top of workflow backends</h2>

<p>Raw YAML is not the interface most ML engineers want. That created a second layer of systems: Pythonic ML pipeline authoring systems that compile or register workflows onto a backend execution system.</p>

<p>Kubeflow Pipelines and Flyte are representative examples, but they choose noticeably different shapes.</p>

<h3 id="kubeflow-pipelines-python-as-an-authoring-and-compilation-layer">Kubeflow Pipelines: Python as an authoring and compilation layer</h3>

<p>In the classic standalone KFP v1 deployment, Kubeflow Pipelines used Argo Workflows as the workflow engine. That does not mean every modern KFP-conformant backend is Argo. KFP v2’s IR and conformant-backend model explicitly decouple authoring from a single execution backend.</p>

<p>The more durable architectural point is this: KFP treats Python primarily as an <strong>authoring and compilation layer</strong>.</p>

<p>You write <code class="language-plaintext highlighter-rouge">@dsl.component</code> and <code class="language-plaintext highlighter-rouge">@dsl.pipeline</code> functions in Python. The SDK compiles the pipeline into an intermediate representation. At run creation time, the backend turns that representation into whatever execution format it supports. In the Argo-backed deployment, that means Argo Workflow resources. In other backends, it may mean something else.</p>

<p>The important property is that the Python pipeline definition is not the runtime process executing every step. Task code is packaged into containers. The pipeline topology has already been compiled into a backend-consumable form. Main and task are not running in the same Python execution context.</p>

<p>This makes KFP feel different from both Airflow and in-process agent frameworks. It is not a scheduler parsing DAG files forever, and it is not a request-time control loop. It is an ML pipeline authoring layer with a compiled execution representation.</p>

<h3 id="flyte-python-as-a-registration-time-entity">Flyte: Python as a registration-time entity</h3>

<p>Flyte is another route to Pythonic ML/data workflows, but it has a different shape.</p>

<p>Flyte makes tasks and workflows Python-decorated entities that are registered with the control plane. At registration time, Flyte serializes task definitions, workflow structure, image references, interface definitions, and other execution metadata into a registered entity.</p>

<p>This gives Flyte a strong type-aware, reproducible, control-plane-centric model. It also means SDK ergonomics matter a lot. If image dependencies, runtime configuration, copied source files, Spark configuration, and workflow structure are colocated in decorators and registration metadata, ordinary operational changes can enter the image or registration loop.</p>

<p>That is not an inherent limitation of Flyte. Flyte provides mechanisms such as reusable container images, <code class="language-plaintext highlighter-rouge">ContainerTask</code>, fast registration, and other ways to separate orchestration from image lifecycle. A disciplined team can keep those layers clean.</p>

<p>The point is narrower: Flyte’s design center makes Python a registration-time source of truth, not just a YAML generator and not an in-process request runtime. That gives it a different coupling surface from KFP. KFP tends to make Python disappear into compiled IR; Flyte tends to preserve Python-defined entities as registered control-plane objects.</p>

<p>That difference matters when thinking about iteration loops, image lifecycle, and the boundary between business logic, orchestration metadata, and runtime environment.</p>

<h2 id="workflow-is-not-ml-pipeline-and-ml-pipeline-is-not-mlops">Workflow is not ML pipeline, and ML pipeline is not MLOps</h2>

<p>This is the section I think many ML infrastructure discussions skip too quickly.</p>

<p>A pipeline engine solves one slice of the problem: how steps connect to each other and run in order. That is useful, but it is not the whole of MLOps.</p>

<p>MLOps, if the term means anything operationally, includes more than step orchestration:</p>

<ul>
  <li>experiment tracking</li>
  <li>run comparison</li>
  <li>model registry</li>
  <li>lineage from data and code to model artifact</li>
  <li>distributed training job integration</li>
  <li>serving and deployment integration</li>
  <li>notebook and development environments</li>
  <li>data and artifact versioning</li>
  <li>reproducible runtime environments</li>
</ul>

<p>KFP runs pipelines. Flyte runs workflows. Argo runs workflows. Airflow runs scheduled DAGs. None of these, on its own, is a complete MLOps platform.</p>

<p>Kubeflow as a broader distribution is closer to a platform because it includes components beyond KFP: training operators, Katib, KServe, notebooks, model registry work, and integrations around the ML lifecycle. But KFP-the-pipeline-engine and Kubeflow-the-ML-platform are not the same thing.</p>

<p>This distinction matters because teams often install a pipeline engine and assume they have installed MLOps. They have not. They have installed orchestration. They still need to decide how experiments are tracked, how models are registered, how training jobs report metrics, how lineage is preserved, how artifacts are promoted, and how serving is connected.</p>

<p>Installing a pipeline engine and calling it MLOps is a bit like installing <code class="language-plaintext highlighter-rouge">make</code> and calling it CI/CD.</p>

<h2 id="the-agent-model-the-scheduling-unit-shrinks-back-into-the-process">The agent model: the scheduling unit shrinks back into the process</h2>

<p>The newest source of confusion is the agent ecosystem adopting the word “workflow.”</p>

<p>This is not wrong exactly. Agent frameworks do orchestrate steps. But their execution contract is very different from Airflow, Argo, KFP, or Flyte.</p>

<p>Google ADK’s older Workflow Agents, such as <code class="language-plaintext highlighter-rouge">SequentialAgent</code>, <code class="language-plaintext highlighter-rouge">ParallelAgent</code>, and <code class="language-plaintext highlighter-rouge">LoopAgent</code>, were deterministic templates for sub-agent execution. Current ADK documentation says these template workflows have been superseded by graph-based and dynamic workflows starting in ADK 2.0. That changes the API surface and flexibility story, but it does not make ADK equivalent to Airflow or Argo.</p>

<p>The design center is still low-latency orchestration of agents, tools, and graph nodes inside an application/request context. A <code class="language-plaintext highlighter-rouge">ParallelAgent</code>-style fan-out is closer to coroutine concurrency over sub-agents than to Kubernetes scheduling. It is useful for parallel LLM/tool calls. It is not the same thing as launching independent Pods across a cluster with durable artifact lineage.</p>

<p>Dify Workflows sit in a similar broad family from an architecture perspective. Dify may persist workflow runs and use background workers for parts of the application, but it should not be confused with a durable batch scheduler like Airflow or a Kubernetes-native workflow controller like Argo. Its design center is application-level orchestration for LLM apps: nodes, variables, prompts, tools, model calls, and user-facing execution.</p>

<p>This explains the practical mismatch.</p>

<p>Putting an LLM agent loop on Airflow introduces a scheduler and task-instance model into a path that wants low-latency token streaming. Putting a daily ETL job on an agent workflow framework gives up the operational contract that ETL teams usually need: backfill, long-running scheduler state, retry policy, historical observability, and failure handling at 3 AM.</p>

<p>Both systems may call the thing a workflow. They are not solving the same problem.</p>

<h2 id="a-more-useful-comparison">A more useful comparison</h2>

<p>The systems become easier to compare if we stop asking which one is the best workflow engine and instead ask what execution contract each one gives us.</p>

<table>
  <thead>
    <tr>
      <th>Question</th>
      <th>Airflow-style ETL</th>
      <th>Argo-style K8s workflow</th>
      <th>KFP / Flyte ML pipeline</th>
      <th>Agent workflow</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>What is the scheduling unit?</td>
      <td>Airflow task through executor / worker backend</td>
      <td>Kubernetes Pod or resource</td>
      <td>containerized task on backend</td>
      <td>in-process function, coroutine, sub-agent, graph node</td>
    </tr>
    <tr>
      <td>Where is the control plane?</td>
      <td>Airflow scheduler and metadata DB</td>
      <td>Kubernetes API + workflow controller</td>
      <td>pipeline control plane / backend</td>
      <td>application process / agent runtime</td>
    </tr>
    <tr>
      <td>When is DAG real?</td>
      <td>parsed from DAG files by scheduler</td>
      <td>submitted as Workflow CR</td>
      <td>compiled IR or registered entity</td>
      <td>request-time or graph-defined execution</td>
    </tr>
    <tr>
      <td>Latency floor</td>
      <td>seconds-scale is normal</td>
      <td>Pod startup scale</td>
      <td>backend/container startup scale</td>
      <td>sub-second to request-scale</td>
    </tr>
    <tr>
      <td>Durability model</td>
      <td>task instance state, retry, backfill</td>
      <td>workflow status, Pod state, artifacts</td>
      <td>pipeline run metadata, task artifacts</td>
      <td>application/run-level persistence, usually not batch scheduler semantics</td>
    </tr>
    <tr>
      <td>Best fit</td>
      <td>scheduled ETL and batch operations</td>
      <td>Kubernetes-native batch orchestration</td>
      <td>reproducible ML pipelines</td>
      <td>interactive LLM applications and agent orchestration</td>
    </tr>
  </tbody>
</table>

<p>This is still simplified, but it is much more useful than a flat feature comparison.</p>

<p>A few examples follow directly:</p>

<ul>
  <li>If you need backfill and scheduled dependency management, start with Airflow-style assumptions.</li>
  <li>If you need Pod-level isolation and Kubernetes-native execution, start with Argo-style assumptions.</li>
  <li>If you need reproducible containerized ML pipelines with Python authoring, start with KFP/Flyte-style assumptions.</li>
  <li>If you need low-latency LLM application control flow, start with agent-workflow assumptions.</li>
</ul>

<p>A system can sometimes be stretched outside its design center, but the stretch should be explicit. Otherwise the team mistakes a naming overlap for architectural compatibility.</p>

<h2 id="a-small-test-before-choosing-anything-called-a-workflow">A small test before choosing anything called a workflow</h2>

<p>Before committing to any workflow system, I would ask these questions in order.</p>

<h3 id="1-what-is-the-scheduling-unit">1. What is the scheduling unit?</h3>

<p>A worker process? A Kubernetes Pod? A containerized backend task? An in-process function call? A coroutine? A sub-agent?</p>

<p>This tells you the latency floor, isolation model, resource boundary, and most of the operational shape.</p>

<h3 id="2-when-does-the-dag-become-real">2. When does the DAG become real?</h3>

<p>A Python file parsed by a scheduler? A YAML custom resource submitted to Kubernetes? A compiled IR? A registered entity? A graph traversed inside a request?</p>

<p>This tells you how dynamic the system really is, and where the control plane lives.</p>

<h3 id="3-what-durability-contract-do-you-need">3. What durability contract do you need?</h3>

<p>Do you need retry and backfill? Artifact lineage? Kubernetes-native status? ML pipeline run metadata? Or do you mostly need request-local orchestration and streaming?</p>

<p>Durability is not a feature checkbox. It is part of the workload contract.</p>

<h3 id="4-are-you-actually-asking-for-workflow-ml-pipeline-or-mlops">4. Are you actually asking for workflow, ML pipeline, or MLOps?</h3>

<p>A workflow engine runs ordered steps. An ML pipeline gives ML-oriented authoring and reproducibility around those steps. MLOps includes the broader lifecycle: experiments, model registry, deployment, lineage, serving, and governance.</p>

<p>If the real problem is MLOps, choosing a workflow engine is only one part of the answer.</p>

<h2 id="closing-thought">Closing thought</h2>

<p>The label “workflow” is fine as marketing, but weak as an architecture tool.</p>

<p>It does not tell you what the scheduling unit is. It does not tell you when the DAG becomes real. It does not tell you what durability contract the system provides. It does not tell you whether the system is built for scheduled ETL, Kubernetes-native batch execution, reproducible ML pipelines, or interactive agent applications.</p>

<p>So I would not start with:</p>

<blockquote>
  <p>Which workflow engine should we use?</p>
</blockquote>

<p>I would start with:</p>

<blockquote>
  <p>What execution contract does this workload need?</p>
</blockquote>

<p>Once that is clear, the comparison becomes much less confusing. Airflow, Argo, KFP, Flyte, Dify, and ADK are not just competing products under one label. They are representative points in different workflow families. Treating them that way makes the design discussion more honest.</p>]]></content><author><name>Zhenyu Sha</name></author><category term="ml-infrastructure" /><category term="platform-engineering" /><category term="Workflow" /><category term="Airflow" /><category term="Argo" /><category term="Kubeflow" /><category term="Flyte" /><category term="Agent" /><category term="MLOps" /><summary type="html"><![CDATA[Airflow, Argo, Kubeflow Pipelines, Flyte, Dify, and Google ADK all call themselves workflow systems. They are not interchangeable. The useful question is not which one is the best workflow engine, but what execution contract each family represents.]]></summary></entry><entry><title type="html">MultiKueue Solves Dispatch, Not Multi-Cloud GPU Placement</title><link href="https://zhenyu.github.io/2026/05/17/multikueue-solves-dispatch-not-gpu-placement-updated/" rel="alternate" type="text/html" title="MultiKueue Solves Dispatch, Not Multi-Cloud GPU Placement" /><published>2026-05-17T23:00:00+00:00</published><updated>2026-05-17T23:00:00+00:00</updated><id>https://zhenyu.github.io/2026/05/17/multikueue-solves-dispatch-not-gpu-placement-updated</id><content type="html" xml:base="https://zhenyu.github.io/2026/05/17/multikueue-solves-dispatch-not-gpu-placement-updated/"><![CDATA[<p>MultiKueue is often presented as a natural answer for multi-cloud GPU scheduling. The framing is understandable: Kueue provides Kubernetes-native queueing, quota, fair sharing, and admission control for batch, HPC, AI/ML, and similar workloads in a Kubernetes cluster. MultiKueue then extends the model across multiple clusters by introducing a manager cluster and worker clusters.</p>

<p>That sounds close to multi-cloud GPU scheduling.</p>

<p>But it is not the same problem.</p>

<p>MultiKueue is useful Kubernetes-native plumbing for dispatching workloads from a manager cluster to worker clusters. That is a real capability. It lets users submit jobs through one control point while executing them on remote Kubernetes clusters. But remote dispatch should not be confused with GPU placement.</p>

<p>For GPU workloads, the hard question is not merely:</p>

<blockquote>
  <p>Which cluster should receive this Kubernetes Job?</p>
</blockquote>

<p>The harder question is:</p>

<blockquote>
  <p>Which resource pool can actually run this workload with the right GPU topology, quota, data locality, cost boundary, tenant policy, and recovery semantics?</p>
</blockquote>

<p>Those are different problems.</p>

<h2 id="what-multikueue-actually-does">What MultiKueue Actually Does</h2>

<p>MultiKueue’s core model is straightforward. A manager cluster connects to one or more worker clusters. The manager creates and monitors remote Workloads or Jobs on worker clusters and synchronizes status back to the local objects. Worker clusters still behave like standalone Kueue clusters.</p>

<p>By default, MultiKueue uses the <code class="language-plaintext highlighter-rouge">AllAtOnce</code> dispatching mode: once the manager-side Workload obtains a <code class="language-plaintext highlighter-rouge">QuotaReservation</code>, the Workload is copied to all available worker clusters. The first worker cluster that admits the remote Workload becomes the selected cluster; the manager then deletes the remote Workloads from the other clusters and creates the corresponding Job in the selected worker cluster.</p>

<p>MultiKueue also supports <code class="language-plaintext highlighter-rouge">Incremental</code> and <code class="language-plaintext highlighter-rouge">External</code> dispatcher modes. <code class="language-plaintext highlighter-rouge">Incremental</code> nominates worker clusters gradually in rounds, while <code class="language-plaintext highlighter-rouge">External</code> delegates worker-cluster nomination to a custom controller. These modes can narrow or customize the candidate set, but they do not change the core boundary: the manager dispatches after its own quota reservation, and the actual worker-side admission result is resolved after dispatch.</p>

<p>In the right scope, this is useful. If an organization owns several Kubernetes clusters and wants a centralized submission path for Kubernetes-native batch workloads, MultiKueue provides a reasonable mechanism.</p>

<p>But that is still a dispatch mechanism.</p>

<p>It does not automatically solve the full placement problem for GPU workloads across clouds, regions, reservations, pricing models, topology constraints, and data boundaries.</p>

<p>This distinction matters because GPU scheduling is not only about moving a Kubernetes object from one API server to another. It is about deciding where a high-value, topology-sensitive, data-sensitive workload should run.</p>

<h2 id="the-awkward-middle-manager-side-quota-approximation">The Awkward Middle: Manager-Side Quota Approximation</h2>

<p>The most awkward part of MultiKueue’s model is the manager-side quota approximation.</p>

<p>The official documentation states that the quota configured in the manager cluster should ideally equal the total quota available across all worker clusters. If the manager quota is significantly lower, worker clusters may remain underutilized. If the manager quota is significantly higher, the manager may dispatch and monitor workloads that are unlikely to be admitted in the worker clusters.</p>

<p>That is the core tension.</p>

<p>If the manager-side quota is conservative, expensive GPUs may sit idle in worker clusters while jobs wait in the manager queue.</p>

<p>If the manager-side quota is optimistic, the manager may send workloads to worker clusters that cannot actually admit them.</p>

<p>Either way, the manager-side quota is not the source of truth. It is a model.</p>

<p>For ordinary batch workloads, this kind of approximation may be acceptable. For GPU workloads, especially expensive multi-GPU or multi-node training jobs, the cost of a weak placement decision is much higher.</p>

<p>A GPU scheduler cannot only ask:</p>

<blockquote>
  <p>Is there some quota somewhere behind a worker cluster?</p>
</blockquote>

<p>It often needs to know:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Which GPU model is available?
Is it H100, H200, A100, L40S, or something else?
Is it PCIe, SXM, NVL, or a full HGX-style node?
Is the workload single-node or multi-node?
Is the required network available?
Is this capacity reserved, on-demand, or preemptible?
Is the data nearby?
Is cross-cloud or cross-region egress allowed?
Which tenant owns the budget?
What is the queue pressure in the target pool?
Can this job recover if the node disappears?
</code></pre></div></div>

<p>These signals are not just implementation details. They are the placement decision.</p>

<h2 id="dispatch-is-not-placement">Dispatch Is Not Placement</h2>

<p>This is the claim boundary that often gets blurred.</p>

<p>Dispatch means:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Take this workload and submit it to a selected worker cluster.
</code></pre></div></div>

<p>Placement means:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Decide which resource pool should run this workload,
given real capacity, queue pressure, GPU topology, data locality,
tenant policy, cost, priority, and failure recovery requirements.
</code></pre></div></div>

<p>MultiKueue helps with the first problem.</p>

<p>It does not, by itself, own the second problem.</p>

<p>This difference is especially important for GPU infrastructure because GPU resources are not fungible in the same way as generic CPU capacity. Eight H100s in one cloud region are not necessarily equivalent to eight H100s in another region or provider. The instance shape, local GPU interconnect, inter-node network, reservation status, storage path, data locality, and operational boundary all matter.</p>

<p>Treating all of that as a remote-cluster dispatch problem hides the hardest part of the system behind a Kubernetes-native abstraction.</p>

<h2 id="kueues-model-is-powerful-but-its-strength-has-a-boundary">Kueue’s Model Is Powerful, But Its Strength Has a Boundary</h2>

<p>Kueue’s internal model is powerful inside a Kubernetes control domain.</p>

<p>A <code class="language-plaintext highlighter-rouge">ClusterQueue</code> governs a resource pool and defines quotas, usage limits, and fair-sharing rules across multiple ClusterQueues. It can manage resources such as pods, CPU, memory, and hardware accelerators.</p>

<p>A <code class="language-plaintext highlighter-rouge">ResourceFlavor</code> represents resource variations and associates them with nodes through labels, taints, and tolerations.</p>

<p>A <code class="language-plaintext highlighter-rouge">Cohort</code> lets ClusterQueues share quota with each other, allowing unused quota to be borrowed within the same sharing structure.</p>

<p>This is a good model for Kubernetes-native quota and admission control.</p>

<p>The problem starts when this model is over-generalized into enterprise multi-cloud GPU scheduling.</p>

<p>A department is a business concept.</p>

<p>A cloud GPU pool is a technical and economic entitlement.</p>

<p>A GPU placement decision is a combination of capacity, topology, cost, policy, and data constraints.</p>

<p>Those concepts do not always map cleanly into a single queue/cohort/flavor graph.</p>

<p>Inside a stable Kubernetes GPU pool, Kueue’s model can be a strong fit.</p>

<p>Across dynamic cloud GPU capacity, the model becomes much harder to reason about.</p>

<h2 id="the-fork-in-the-road">The Fork in the Road</h2>

<p>MultiKueue faces a fundamental fork.</p>

<p>If it does not know real worker-side quota, queue pressure, GPU topology, data locality, tenant budget, and cost constraints before dispatch, then it can only make a best-effort remote submission.</p>

<p>If it does know all of those signals before dispatch, then the real placement decision is already being made somewhere else. At that point, MultiKueue becomes one execution path, not the top-level scheduling abstraction.</p>

<p>This is why the manager-side quota approximation is not a minor detail. It reveals the deeper issue: MultiKueue is not designed to be the full source of truth for multi-cloud GPU placement.</p>

<p>It can move jobs across clusters.</p>

<p>But moving jobs is not the same as deciding where expensive GPU workloads should run.</p>

<h2 id="where-multikueue-fits">Where MultiKueue Fits</h2>

<p>The point is not that MultiKueue is useless.</p>

<p>It is useful for what it is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Kubernetes-native multi-cluster job dispatch.
</code></pre></div></div>

<p>It is a reasonable fit when:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>The organization owns multiple Kubernetes clusters.
The clusters are relatively stable.
The workloads are Kubernetes-native.
The main goal is centralized submission and remote execution.
The placement policy is simple enough to be approximated at the manager.
</code></pre></div></div>

<p>But it should not be over-sold as:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>A complete enterprise multi-cloud GPU scheduler.
</code></pre></div></div>

<p>That broader problem requires placement decisions based on signals that go beyond remote Kubernetes object creation.</p>

<h2 id="conclusion">Conclusion</h2>

<p>MultiKueue solves dispatch, not multi-cloud GPU placement.</p>

<p>That distinction matters.</p>

<p>For GPU workloads, the hard part is not simply sending a Job to another cluster. The hard part is deciding which resource pool should run the workload in the first place, given GPU topology, quota, queue pressure, data locality, tenant policy, cost, and recovery requirements.</p>

<p>MultiKueue is useful Kubernetes-native plumbing. But treating it as the center of a multi-cloud GPU scheduling architecture risks overestimating what remote dispatch can solve.</p>

<p>The right claim boundary is simple:</p>

<blockquote>
  <p>MultiKueue can help dispatch Kubernetes workloads across clusters.<br />
It should not be confused with a full multi-cloud GPU placement system.</p>
</blockquote>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://kueue.sigs.k8s.io/">Kueue overview</a></li>
  <li><a href="https://kueue.sigs.k8s.io/docs/concepts/multikueue/">MultiKueue concept</a></li>
  <li><a href="https://kueue.sigs.k8s.io/docs/concepts/cluster_queue/">ClusterQueue concept</a></li>
  <li><a href="https://kueue.sigs.k8s.io/docs/concepts/resource_flavor/">ResourceFlavor concept</a></li>
  <li><a href="https://kueue.sigs.k8s.io/docs/concepts/cohort/">Cohort concept</a></li>
</ul>]]></content><author><name>Zhenyu Sha</name></author><category term="ml-infrastructure" /><category term="kubernetes" /><category term="MultiKueue" /><category term="Kueue" /><category term="Kubernetes" /><category term="GPU" /><category term="Scheduling" /><category term="ML Infrastructure" /><summary type="html"><![CDATA[MultiKueue is useful Kubernetes-native plumbing for multi-cluster job dispatch, but it should not be over-sold as a complete multi-cloud GPU placement system.]]></summary></entry><entry><title type="html">Design Patterns for Large-Scale Multimodal Training Data Pipelines</title><link href="https://zhenyu.github.io/2026/05/15/large-scale-multimodal-training-data-pipelines/" rel="alternate" type="text/html" title="Design Patterns for Large-Scale Multimodal Training Data Pipelines" /><published>2026-05-15T00:00:00+00:00</published><updated>2026-05-15T00:00:00+00:00</updated><id>https://zhenyu.github.io/2026/05/15/large-scale-multimodal-training-data-pipelines</id><content type="html" xml:base="https://zhenyu.github.io/2026/05/15/large-scale-multimodal-training-data-pipelines/"><![CDATA[<p>I wrote this mostly to clarify my own thinking.</p>

<p>When I look at large-scale multimodal training data systems, I keep seeing the same confusion: people compare tools before agreeing on which lifecycle stage they are optimizing. A data curation system, a storage layout, a training-time <code class="language-plaintext highlighter-rouge">DataLoader</code>, and a cache strategy are related, but they are not the same layer.</p>

<p>So this post is not a benchmark report, and it is not a universal architecture recommendation. It is a personal reasoning framework: how I currently think about the design space, what patterns seem to appear repeatedly, and where I think the real trade-offs are.</p>

<p>The core question I keep coming back to is:</p>

<blockquote>
  <p>At which point in the data lifecycle should a pipeline stop being dynamic and become a stable training artifact?</p>
</blockquote>

<p>That boundary determines how much flexibility the system keeps, how much throughput the training loop can get, and how expensive dataset evolution becomes.</p>

<hr />

<h2 id="1-the-real-problem-data-lifecycle-not-just-data-loading">1. The real problem: data lifecycle, not just data loading</h2>

<p>A multimodal training pipeline usually has more stages than the final <code class="language-plaintext highlighter-rouge">DataLoader</code>.</p>

<p>A simplified lifecycle looks like this:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>raw recording
  -&gt; curation / filtering / alignment
  -&gt; metadata and blob organization
  -&gt; sampling / joining / transformation
  -&gt; dataset materialization
  -&gt; high-throughput training
</code></pre></div></div>

<p>Different stages optimize for different things.</p>

<p>The recording stage cares about append safety, durability, and replay.<br />
The curation stage cares about dynamic filtering, joins, decoding, enrichment, and versioning.<br />
The training stage cares about throughput, shuffle quality, deterministic rank behavior, cache reuse, and failure recovery.</p>

<p>The mistake is to force one abstraction to optimize all of these stages equally well.</p>

<p>That is why I find it useful to reason in terms of <strong>patterns</strong>, rather than individual tools.</p>

<hr />

<h2 id="2-a-few-design-dimensions">2. A few design dimensions</h2>

<p>Before comparing patterns, I separate the problem into a few dimensions.</p>

<h3 id="21-storage-layout">2.1 Storage layout</h3>

<p>There are two broad directions.</p>

<p>The first is a <strong>training-sample-oriented layout</strong>:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>shard-000.tar / shard-000.mds / shard-000.tfrecord
shard-001.tar / shard-001.mds / shard-001.tfrecord
...
</code></pre></div></div>

<p>Samples are mostly aligned before training. The training loop streams through shards.</p>

<p>The second is a <strong>metadata + blob layout</strong>:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>metadata/
  episodes.parquet
  frames.parquet
  tasks.parquet

videos/
  camera_front/...
  camera_left/...
  camera_right/...

other_blobs/
  lidar/...
  embeddings/...
</code></pre></div></div>

<p>Metadata is queryable. Large objects stay as separate blobs. Alignment and sampling can happen later.</p>

<h3 id="22-execution-model">2.2 Execution model</h3>

<p>One direction is <strong>per-rank iterable execution</strong>:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rank 0 -&gt; assigned shards -&gt; local workers -&gt; batches
rank 1 -&gt; assigned shards -&gt; local workers -&gt; batches
rank 2 -&gt; assigned shards -&gt; local workers -&gt; batches
</code></pre></div></div>

<p>Each rank mostly reads independently. This keeps the training loop simple.</p>

<p>Another direction is <strong>distributed data execution</strong>:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>read -&gt; filter -&gt; join -&gt; decode -&gt; transform -&gt; batch
</code></pre></div></div>

<p>The pipeline is a distributed DAG. CPU and GPU stages may scale differently. Data may move across nodes.</p>

<h3 id="23-cache-semantics">2.3 Cache semantics</h3>

<p>Cache is not one thing.</p>

<p>A pipeline may cache:</p>

<ul>
  <li>pre-sharded files on local SSD</li>
  <li>decoded or partially decoded blocks in a distributed object store</li>
  <li>metadata indices</li>
  <li>rolling windows of materialized shards</li>
  <li>temporary spill files</li>
  <li>dataloader worker buffers</li>
</ul>

<p>These have different lifetimes, eviction behavior, and failure semantics. A good architecture should discuss cache explicitly rather than treating it as a detail hidden behind the loader.</p>

<h3 id="24-dataset-evolution">2.4 Dataset evolution</h3>

<p>Some datasets are release-once artifacts. Others change constantly:</p>

<ul>
  <li>filtering rules change</li>
  <li>bad data is removed</li>
  <li>new sensors or modalities are added</li>
  <li>sampling weights change</li>
  <li>curation models are updated</li>
  <li>training stages use different views of the same underlying data</li>
</ul>

<p>The faster the dataset evolves, the more expensive early materialization becomes.</p>

<hr />

<h2 id="3-three-useful-patterns">3. Three useful patterns</h2>

<p>I currently find it helpful to classify common systems into three rough patterns.</p>

<p>These are not strict categories. Real systems often combine them, and the same organization may use different patterns at different lifecycle stages.</p>

<h3 id="pattern-a-direct-random-access-loader-over-metadata-and-blobs">Pattern A: direct random-access loader over metadata and blobs</h3>

<p>This is the simplest flexible baseline.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>metadata table + blob files
  -&gt; custom Dataset / DataLoader
  -&gt; per-sample fetch / decode / transform
  -&gt; batch
</code></pre></div></div>

<p>Examples:</p>

<ul>
  <li>a PyTorch <code class="language-plaintext highlighter-rouge">Dataset</code> reading Parquet rows and video files</li>
  <li>a robotics dataset loader reading episode metadata and MP4 clips</li>
  <li>a custom autonomous-driving loader joining sensor files at runtime</li>
</ul>

<p>This pattern is attractive because it is easy to start with. It preserves flexibility and avoids expensive pre-sharding.</p>

<p>But at scale, it can become difficult:</p>

<ul>
  <li>many small metadata reads</li>
  <li>many object-store requests</li>
  <li>runtime joins in the training path</li>
  <li>limited global optimization</li>
  <li>cache behavior hidden inside application code</li>
  <li>hard-to-control per-rank skew</li>
</ul>

<p>Pattern A is often a good research or early-stage format. It is rarely the final answer for very large, high-throughput training.</p>

<h3 id="pattern-b-distributed-streaming-dag">Pattern B: distributed streaming DAG</h3>

<p>Pattern B keeps the data pipeline dynamic, but moves the work into a distributed execution layer.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>metadata + blobs
  -&gt; distributed read / filter / join / decode / transform
  -&gt; distributed shuffle or block-level sampling
  -&gt; batches or materialized dataset
</code></pre></div></div>

<p>Examples of tools in this family include Ray Data, Dask, Spark-like systems, and custom distributed curation pipelines.</p>

<p>This pattern is useful when the pipeline contains:</p>

<ul>
  <li>large-scale filtering</li>
  <li>video decoding and clipping</li>
  <li>embedding generation</li>
  <li>captioning or labeling</li>
  <li>multi-source joins</li>
  <li>CPU-heavy geometric transforms</li>
  <li>dataset version construction</li>
  <li>dynamic sampling logic</li>
</ul>

<p>Pattern B is not automatically faster than a good pre-sharded training loader. Its value is flexibility and resource decoupling.</p>

<p>It can scale CPU-heavy curation separately from GPU-heavy training. It can express heterogeneous stages more naturally. It can delay materialization until the dataset view is stable enough.</p>

<p>The cost is operational complexity:</p>

<ul>
  <li>block sizing matters</li>
  <li>shuffle strategy matters</li>
  <li>object-store pressure matters</li>
  <li>actor or worker pool sizing matters</li>
  <li>locality is harder to guarantee</li>
  <li>default settings may run but not be optimal</li>
</ul>

<p>Pattern B buys dynamicity. It does not buy free performance.</p>

<h3 id="pattern-c-pre-sharded-training-format-with-per-rank-iterable-loading">Pattern C: pre-sharded training format with per-rank iterable loading</h3>

<p>Pattern C materializes the dataset into a training-friendly format before the final training loop.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curated training shards
  -&gt; per-rank iterable loader
  -&gt; local shuffle / decode / transform
  -&gt; training step
</code></pre></div></div>

<p>Examples:</p>

<ul>
  <li>WebDataset tar shards</li>
  <li>Mosaic MDS</li>
  <li>TFRecord shards</li>
  <li>Grain/tf.data-style sharded iterable pipelines</li>
  <li>precomputed video/image shards consumed by PyTorch DataLoader</li>
</ul>

<p>This pattern is very strong when the dataset is stable.</p>

<p>Its advantages are clear:</p>

<ul>
  <li>short training data path</li>
  <li>predictable rank-to-shard assignment</li>
  <li>good sequential IO</li>
  <li>natural local SSD cache</li>
  <li>simpler failure model</li>
  <li>high throughput when samples are well aligned</li>
  <li>fewer runtime joins in the training path</li>
</ul>

<p>This is often the best final training-loop pattern.</p>

<p>But it depends on assumptions:</p>

<ul>
  <li>training samples can be pre-aligned</li>
  <li>preprocessing is stable enough</li>
  <li>rematerialization is not too expensive</li>
  <li>rank topology is predictable</li>
  <li>cache can be reused across epochs or runs</li>
  <li>one shard layout can serve the workload well</li>
</ul>

<p>When these assumptions hold, Pattern C is hard to beat. When they do not, the cost of pre-sharding can dominate.</p>

<hr />

<h2 id="4-comparing-the-patterns">4. Comparing the patterns</h2>

<p>A simplified comparison looks like this:</p>

<table>
  <thead>
    <tr>
      <th>Dimension</th>
      <th>Pattern A: direct random access</th>
      <th>Pattern B: distributed streaming DAG</th>
      <th>Pattern C: pre-sharded iterable</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Startup cost</td>
      <td>Low</td>
      <td>Medium</td>
      <td>High</td>
    </tr>
    <tr>
      <td>Flexibility</td>
      <td>High</td>
      <td>High</td>
      <td>Low to medium</td>
    </tr>
    <tr>
      <td>Training-loop throughput</td>
      <td>Low to medium</td>
      <td>Medium</td>
      <td>High</td>
    </tr>
    <tr>
      <td>Runtime joins</td>
      <td>Common</td>
      <td>Explicit in DAG</td>
      <td>Mostly avoided</td>
    </tr>
    <tr>
      <td>Cache semantics</td>
      <td>Application-defined</td>
      <td>Object/block/window-level</td>
      <td>File/shard-level</td>
    </tr>
    <tr>
      <td>Resource scaling</td>
      <td>Per training job</td>
      <td>Per pipeline stage</td>
      <td>Mostly tied to training ranks</td>
    </tr>
    <tr>
      <td>Dataset evolution</td>
      <td>Easy</td>
      <td>Easy to medium</td>
      <td>Expensive if frequent</td>
    </tr>
    <tr>
      <td>Operational complexity</td>
      <td>Hidden in app code</td>
      <td>High but explicit</td>
      <td>Medium and predictable</td>
    </tr>
    <tr>
      <td>Best use case</td>
      <td>early experimentation</td>
      <td>curation and dynamic assembly</td>
      <td>stable high-throughput training</td>
    </tr>
  </tbody>
</table>

<p>This table is intentionally rough. The point is not that one pattern is universally better. The point is that each pattern has a different design contract.</p>

<hr />

<h2 id="5-when-pattern-a-is-enough">5. When Pattern A is enough</h2>

<p>Pattern A is often a good starting point.</p>

<p>If the dataset is small enough, the number of modalities is limited, and training throughput is not yet the bottleneck, a direct loader over metadata and blobs may be the simplest solution.</p>

<p>This is especially true during early dataset exploration:</p>

<ul>
  <li>validating data schema</li>
  <li>debugging alignment</li>
  <li>inspecting episodes</li>
  <li>testing new filtering logic</li>
  <li>iterating on model input format</li>
</ul>

<p>The danger is that Pattern A can survive too long. Once the same runtime joins and blob fetches happen repeatedly in every training run, the system may be paying curation cost inside the training loop.</p>

<p>A useful question is:</p>

<blockquote>
  <p>Are we doing one-time data assembly work repeatedly during training?</p>
</blockquote>

<p>If yes, Pattern A may need to evolve into Pattern B or C.</p>

<hr />

<h2 id="6-when-pattern-b-is-the-right-center-of-gravity">6. When Pattern B is the right center of gravity</h2>

<p>Pattern B becomes attractive when the data view is still changing.</p>

<p>For example:</p>

<ul>
  <li>the team is still changing filtering rules</li>
  <li>multiple training stages need different sample views</li>
  <li>preprocessing includes CPU-heavy sensor alignment</li>
  <li>video clips must be decoded, split, embedded, captioned, or re-encoded</li>
  <li>metadata and blob lifecycle need to be versioned together</li>
  <li>workloads require different CPU/GPU ratios</li>
  <li>the same data platform serves many teams or training jobs</li>
</ul>

<p>In these cases, making the training format too early can create unnecessary churn. Every change may require rematerializing a large dataset.</p>

<p>Pattern B lets the system keep more of the pipeline dynamic until the dataset view becomes stable.</p>

<p>But Pattern B should not be mistaken for a magical training loader. If every training step depends on a complex distributed DAG, the training loop inherits the complexity of the data system.</p>

<p>That may be acceptable for some workloads. For many workloads, Pattern B is better used to produce a stable artifact that Pattern C can consume.</p>

<hr />

<h2 id="7-when-pattern-c-is-the-right-target">7. When Pattern C is the right target</h2>

<p>Pattern C is the natural target once the dataset view stabilizes.</p>

<p>It is especially strong when:</p>

<ul>
  <li>the training loop dominates cost</li>
  <li>data samples can be pre-aligned</li>
  <li>the same dataset will be reused many times</li>
  <li>cache reuse matters</li>
  <li>shuffle requirements can be approximated by shard-level and buffer-level methods</li>
  <li>training rank topology is stable enough</li>
  <li>runtime joins are unnecessary or expensive</li>
</ul>

<p>This is why many high-throughput training systems eventually converge toward some kind of pre-sharded format.</p>

<p>The interesting question is not whether Pattern C is fast. It is.</p>

<p>The interesting question is:</p>

<blockquote>
  <p>Is the dataset stable enough to justify materializing it into Pattern C?</p>
</blockquote>

<p>If the answer is yes, Pattern C is often the cleanest final training-loop design.</p>

<p>If the answer is no, forcing Pattern C too early can create a heavy rematerialization tax.</p>

<hr />

<h2 id="8-cache-economics">8. Cache economics</h2>

<p>Cache is one of the main reasons the boundary is hard.</p>

<p>Pattern C has the cleanest cache story. If training shards live on local SSD, the unit of reuse is explicit. The training job can reuse physical files across epochs or runs.</p>

<p>Pattern B can also reuse work, but through different mechanisms:</p>

<ul>
  <li>materialized distributed blocks</li>
  <li>object-store-backed cache</li>
  <li>spill to local disk</li>
  <li>rolling shard materialization</li>
  <li>application-level cache windows</li>
</ul>

<p>These mechanisms can reduce repeated object-store reads, but they are not equivalent to deterministic local shard cache.</p>

<p>So I would not say that Pattern B eliminates Pattern C’s cache advantage. A better statement is:</p>

<blockquote>
  <p>Pattern B can reduce the exclusivity of Pattern C’s cache story, but cache still needs to be designed explicitly.</p>
</blockquote>

<p>A useful way to reason about cache is:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>cache hit rate is bounded by active working set, cache capacity, sampling distribution, and invalidation frequency
</code></pre></div></div>

<p>If the active working set is far larger than aggregate cache and global reshuffling changes access patterns every epoch, cache reuse may be limited.</p>

<p>If the same curated shards are reused across many runs, Pattern C cache can be extremely effective.</p>

<hr />

<h2 id="9-an-open-storage-layer-subquestion-parquet--blobs-lance-and-vortex">9. An open storage-layer subquestion: Parquet + blobs, Lance, and Vortex</h2>

<p>This section is intentionally secondary to the main argument.</p>

<p>The main question of this post is where to place the materialization boundary in a multimodal training data lifecycle. Storage formats such as Parquet, Lance, and Vortex matter after we decide that part of the pipeline should remain in a metadata + blob layout.</p>

<p>So I treat this section as an open storage-layer subquestion, not as the core thesis of the post.</p>

<h3 id="91-parquet--independent-blobs">9.1 Parquet + independent blobs</h3>

<p>Parquet plus independent blob files is a strong baseline for metadata + blob datasets.</p>

<p>It is easy to inspect, easy to integrate with data tools, and easy to evolve early on. It works well when the team is still exploring schemas and sampling logic.</p>

<p>The downside is that consistency and lifecycle management become application responsibilities:</p>

<ul>
  <li>which blob files are still referenced?</li>
  <li>which metadata version points to which blob version?</li>
  <li>how are old versions cleaned up?</li>
  <li>how are paths relocated across buckets?</li>
  <li>how are runtime joins optimized?</li>
</ul>

<p>For stable or small datasets, this may be fine. For frequently evolving datasets, it becomes operational work.</p>

<h3 id="92-lance">9.2 Lance</h3>

<p>My current hypothesis is that Lance’s value in video-heavy multimodal workloads may be less about codec-level throughput and more about dataset governance.</p>

<p>For large opaque video blobs, I would not expect Lance to magically improve video decoding. The video still has to be read, sought, and decoded. The possible value is elsewhere:</p>

<ul>
  <li>dataset-level versioning</li>
  <li>manifest consistency</li>
  <li>blob lifecycle management</li>
  <li>metadata/blob coordination</li>
  <li>selective access through metadata</li>
  <li>path relocation and governance</li>
</ul>

<p>This could be valuable even without a codec-level performance win. But I would not treat it as a settled conclusion without workload-specific measurement.</p>

<p>So my current framing is:</p>

<blockquote>
  <p>Lance may be most interesting when metadata/blob lifecycle is becoming a system problem, not merely when raw video decode throughput is the bottleneck.</p>
</blockquote>

<h3 id="93-vortex">9.3 Vortex</h3>

<p>My tentative read is that Vortex is more naturally relevant to metadata-heavy or feature-table-like access patterns.</p>

<p>That may matter for large-scale filtering, sampling, and feature access. But for already-compressed video blobs, a columnar format does not by itself solve GOP-level seek or video decode.</p>

<p>Whether Vortex matters for a video-heavy training pipeline depends on how much of the end-to-end cost is actually in metadata access and sampling, rather than blob IO and decode.</p>

<p>So I would frame Vortex as a candidate to watch for the metadata-heavy parts of the pipeline, not as a direct replacement for video-aware storage and decode design.</p>

<h3 id="94-webdataset--mds--tfrecord-style-shards">9.4 WebDataset / MDS / TFRecord-style shards</h3>

<p>These formats are closer to the Pattern C side of the boundary.</p>

<p>They are excellent when samples are already curated and aligned. They reduce runtime joining and make the training loop more predictable.</p>

<p>Their cost is that they encode a more fixed view of the dataset.</p>

<p>This is why I think storage-format discussion should be downstream of the lifecycle question. First decide what should remain dynamic and what should be materialized. Then choose the format that matches that role.</p>

<hr />

<h2 id="10-recording-format-is-not-training-format">10. Recording format is not training format</h2>

<p>Robotics and autonomous-driving systems often start with recording formats such as rosbag or MCAP.</p>

<p>These formats optimize for a different lifecycle stage.</p>

<p>Recording formats care about:</p>

<ul>
  <li>append-safe logging</li>
  <li>multiple producers writing independently</li>
  <li>time-indexed replay</li>
  <li>message-level atomicity</li>
  <li>robustness to dropped frames, late messages, clock skew, and crashes</li>
</ul>

<p>Training formats care about:</p>

<ul>
  <li>sample-level random access</li>
  <li>modality-specific filtering</li>
  <li>aligned clips or frames</li>
  <li>efficient decode</li>
  <li>batching and shuffling</li>
  <li>repeated access across many epochs or experiments</li>
</ul>

<p>This is an invariant conversion:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>raw recording invariant:
  message-level atomic, append-safe, time-indexed

training invariant:
  aligned, sampleable, seekable, batchable
</code></pre></div></div>

<p>Video is a good example. Video compression works well because frames are organized as coherent temporal sequences. That assumption is usually not safe at raw recording time. It becomes safe only after curation has established the right clip-level invariant.</p>

<p>So the benefit of video is not just that codecs are good. It is that curation changes the semantic structure of the data enough for video compression and seek behavior to become useful.</p>

<hr />

<h2 id="11-a-better-way-to-frame-the-architecture-decision">11. A better way to frame the architecture decision</h2>

<p>Instead of asking:</p>

<blockquote>
  <p>Which tool should we use?</p>
</blockquote>

<p>I think the better sequence is:</p>

<ol>
  <li>What is the current data lifecycle?</li>
  <li>Which stages are still changing?</li>
  <li>Which stages are repeated often enough to justify materialization?</li>
  <li>What is the active working set?</li>
  <li>What cache semantics do we need?</li>
  <li>How stable is the training topology?</li>
  <li>How expensive is rematerialization?</li>
  <li>How much governance do metadata and blobs need?</li>
</ol>

<p>Only then should we choose tools.</p>

<p>A rough decision rule:</p>

<ul>
  <li>Start with Pattern A if the dataset is small or still being understood.</li>
  <li>Move to Pattern B when curation, filtering, joining, or preprocessing becomes large and dynamic.</li>
  <li>Materialize into Pattern C when the dataset view is stable enough and training-loop throughput dominates.</li>
</ul>

<p>Many production systems will use all three at different stages.</p>

<hr />

<h2 id="12-my-current-mental-model">12. My current mental model</h2>

<p>The core architecture decision is the <strong>materialization boundary</strong>.</p>

<p>Move the boundary earlier:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curation -&gt; pre-sharded training dataset -&gt; simple training loop
</code></pre></div></div>

<p>You get a faster, simpler training loop, but dataset evolution becomes more expensive.</p>

<p>Move the boundary later:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curation + filtering + sampling remain dynamic -&gt; training consumes dynamic stream
</code></pre></div></div>

<p>You get more flexibility, but the training loop inherits more distributed data complexity.</p>

<p>There is no universal best point. The right boundary depends on:</p>

<ul>
  <li>dataset volatility</li>
  <li>preprocessing complexity</li>
  <li>number of workloads</li>
  <li>active working set size</li>
  <li>cache capacity</li>
  <li>object-store economics</li>
  <li>training topology stability</li>
  <li>governance and versioning needs</li>
  <li>cost of rebuilding training shards</li>
</ul>

<p>That is why I think “Ray Data vs. WebDataset” is the wrong top-level framing.</p>

<p>The better framing is:</p>

<blockquote>
  <p>Which parts of the pipeline should remain dynamic, and which parts should become stable training artifacts?</p>
</blockquote>

<hr />

<h2 id="13-what-i-would-still-want-to-benchmark">13. What I would still want to benchmark</h2>

<p>This post is mostly a reasoning framework. To turn it into an architecture decision for a specific workload, I would want measurements.</p>

<p>Some questions I would benchmark:</p>

<ul>
  <li>How much time is spent in metadata access, blob fetch, decode, transform, and batching?</li>
  <li>How expensive is global shuffle compared with shard-level shuffle plus local buffer shuffle?</li>
  <li>How effective is local SSD shard cache for the active working set?</li>
  <li>How effective is distributed block materialization or rolling-window cache?</li>
  <li>How often does the dataset change enough to invalidate pre-sharded artifacts?</li>
  <li>How much operational complexity does metadata/blob lifecycle create?</li>
  <li>Does Lance reduce governance complexity enough to justify migration?</li>
  <li>Does Vortex help metadata-heavy sampling or filtering enough to matter?</li>
</ul>

<p>Without those numbers, the safest conclusion is not “Pattern B wins” or “Pattern C wins.”</p>

<p>The safer conclusion is:</p>

<blockquote>
  <p>Pick the materialization boundary based on workload volatility, cache economics, and training-loop throughput requirements.</p>
</blockquote>

<hr />

<h2 id="closing-thought">Closing thought</h2>

<p>A large-scale multimodal training data pipeline is not just a loader.</p>

<p>It is a lifecycle system that transforms raw recordings into stable training signals.</p>

<p>The most important design decision is not only the file format or the execution engine. It is deciding when data should remain dynamic and when it should become a training artifact.</p>

<p>That boundary is where most of the real architecture trade-off lives.</p>]]></content><author><name>Zhenyu Sha</name></author><category term="ml-infrastructure" /><category term="data-pipeline" /><category term="distributed-systems" /><category term="multimodal" /><category term="training-data" /><category term="data-pipeline" /><category term="ray-data" /><category term="webdataset" /><category term="lance" /><category term="vortex" /><summary type="html"><![CDATA[I wrote this mostly to clarify my own thinking.]]></summary></entry></feed>