<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://hectopascal.github.io/feed.xml" rel="self" type="application/atom+xml"/><link href="https://hectopascal.github.io/" rel="alternate" type="text/html" hreflang="en"/><updated>2026-05-10T16:02:37+00:00</updated><id>https://hectopascal.github.io/feed.xml</id><title type="html">My Website</title><subtitle>A simple, whitespace theme for academics. Based on [*folio](https://github.com/bogoli/-folio) design. </subtitle><entry><title type="html">A VLM, FSDP, and the Lie My Strong-Scaling Numbers Told Me</title><link href="https://hectopascal.github.io/blog/2026/vlm-fsdp-strong-scaling/" rel="alternate" type="text/html" title="A VLM, FSDP, and the Lie My Strong-Scaling Numbers Told Me"/><published>2026-05-08T15:09:00+00:00</published><updated>2026-05-08T15:09:00+00:00</updated><id>https://hectopascal.github.io/blog/2026/vlm-fsdp-strong-scaling</id><content type="html" xml:base="https://hectopascal.github.io/blog/2026/vlm-fsdp-strong-scaling/"><![CDATA[<p><a href="https://github.com/hectopascal/tinyvlm-implementation">link to github repo</a></p> <h1 id="why-i-built-this">Why I built this</h1> <p>I built a tiny vision-language model because I wanted to understand what happens below the library abstraction.</p> <p>Not “call AutoModelForVision2Seq and hope for the best” understand. I mean the slightly more annoying version: how image embeddings actually enter a language model, what the projector is doing, why the training is staged, and what breaks when the setup moves from one GPU to multi-GPU FSDP.</p> <p>The project had two parts.</p> <p>First, I implemented a small VLM using a SigLIP-2 vision encoder, a Qwen2.5 language model, and a two-layer MLP projector. I also implemented the image-token splice manually: replace the <code class="language-plaintext highlighter-rouge">&lt;image&gt;</code> placeholder token in the text sequence with projected image patch embeddings, then feed the resulting continuous multimodal sequence into the LM.</p> <p>Second, I scaled the setup with FSDP across 2, 4, and 8 V100s, using a larger Qwen2.5-1.5B LM and the full LLaVA-Pretrain dataset. I expected scaling efficiency to degrade at 8 GPUs. Instead, I initially got superlinear speedup.</p> <p>Naturally, this was suspicious. Computers are many things, but they are rarely generous.</p> <h1 id="the-model">The model</h1> <p>SigLIP vision encoder -&gt; projector + Qwen LM token stream.</p> <p>The important mental model is that the image is not magical to the language model. After projection, image patches become embedding vectors inserted into the LM’s sequence.</p> <p>The splice operation is the runtime trick that makes this work. The text contains an <code class="language-plaintext highlighter-rouge">&lt;image&gt;</code> bookmark token. During multimodal preprocessing, that bookmark is replaced with the image patch embeddings. The LM then sees one long embedding sequence: some text, then image-derived vectors, then more text.</p> <p>This is the part I wanted to implement by hand. Not because the code is glamorous, but because this is where the abstraction becomes concrete. A lot of VLM architecture becomes less mysterious once you see that the “multimodal” part is, in practice, a carefully arranged embedding sequence.</p> <h1 id="training-stages">Training stages</h1> <p>I used a two-stage training setup.</p> <p>In stage 1, the projector is trained to align the vision encoder’s output with the language model’s embedding space. The vision encoder and LM are mostly fixed; the projector learns to produce embeddings that the LM can consume usefully.</p> <p>In stage 2, the LM is unfrozen and fine-tuned together with the projector, using LoRA. At this point, the model can adapt more broadly, but it is no longer just learning “how do I translate vision features into LM-space?” It is also changing how the LM responds to those multimodal inputs.</p> <p>I cared more about the implementation and scaling behavior than squeezing out the best VLM quality. The model produced short, on-topic, generic captions after stage 1, which was enough to confirm that the data path worked. The interesting part came later, when the training loop met distributed systems and immediately became less innocent.</p> <h1 id="scaling-setup">Scaling setup</h1> <p>For the scaling study, I used Qwen2.5-1.5B as the language model and trained with FSDP across 2, 4, and 8 V100s. The original plan was to use A100s. Then cloud pricing performed its usual spiritual cleansing exercise on my ambitions, so V100s it was.</p> <h1 id="results">Results</h1> <h2 id="the-interesting-result-superlinear-scaling">The interesting result: superlinear scaling?</h2> <p>The first strong-scaling result looked great.</p> <p>Too great.</p> <p>The no-checkpoint runs appeared to show superlinear scaling: 8 GPUs looked <strong>5.8× faster</strong> than 2 GPUs, significantly above the 4× ideal.</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/scaling-480.webp 480w,/assets/img/scaling-800.webp 800w,/assets/img/scaling-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/scaling.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>I reran the experiment and profiled the memory which showed that the 2-GPU baseline was under memory pressure, using about 12.94GB of 16GB, so the performance was likely worse than what a clean baseline should be. The original baseline was unhealthy.</p> <p>Activation checkpointing reduced 2-GPU memory to 9.93GB and gave a much more honest near-linear result: <strong>4.07× from 2 to 8 GPUs, about 102% of ideal</strong>.</p> <p>The lesson: strong-scaling numbers are only as honest as the baseline. If the smallest configuration is memory-bound, larger configurations can look artificially impressive. You are not measuring pure parallel efficiency anymore. You are measuring parallelism plus the relief of memory pressure.</p> <h2 id="profiling">Profiling</h2> <p>Then I profiled the 8-GPU run. The trace showed communication-bound behavior. FSDP all-gathers were keeping the NCCL stream busy, while the compute stream had idle gaps between layers. In other words, the GPUs were often waiting for parameters to arrive before they could do useful work.</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/nockpt-480.webp 480w,/assets/img/nockpt-800.webp 800w,/assets/img/nockpt-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/nockpt.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>Measuring within a single ProfilerStep, the compute stream was occupied roughly 40% of the time, while the NCCL stream ran continuously across the entire forward pass — three back-to-back nccl:_all_gather_base calls with no gaps. The GPU was idle more than half the step, waiting for parameters to arrive.</p> <p>So I tried the textbook fix: fsdp_forward_prefetch=True. This allows the model to fetch parameters for the next layer early while it is computing the current layer. In theory, this should hide communication behind computation. PyTorch’s FSDP tutorial describes prefetching as a way to overlap all-gathers with computation.</p> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/nockpt_prefetch-480.webp 480w,/assets/img/nockpt_prefetch-800.webp 800w,/assets/img/nockpt_prefetch-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/nockpt_prefetch.png" class="img-fluid rounded z-depth-1" width="100%" height="auto" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> <p>In the profiler trace, it did what it was supposed to do. The NCCL all-gathers became more tightly packed, and there was visible overlap.</p> <p>However, throughput did not improve meaningfully: 3059 ± 99 tok/s versus 3164 ± 59 tok/s.</p> <p>Diagnosis: bandwidth contention.The 8×V100 instance I used did not appear to have the all-to-all NVSwitch topology typical of newer A100/H100 training boxes. On V100 NVLink, the link is already saturated during all-gather, so overlapping doesn’t help. We’re just shifting bottleneck time around without reducing it. The trace shows overlap but the wall clock shows it didn’t matter because the bandwidth ceiling was the limit, not the scheduling.</p> <h1 id="what-i-learned">What I learned</h1> <p>My main takeaway is to interpret my results with suspicion, and always make sure you have a clean baseline.</p> <p>The first result said: “8 GPUs gives 5.80× speedup over 2 GPUs.”</p> <p>The better interpretation was: “The 2-GPU baseline is memory-constrained, so the apparent scaling efficiency is inflated.”</p> <p>Activation checkpointing fixed the interpretation. It reduced memory pressure in the baseline and recovered a much more honest scaling picture: near-linear 2-to-8 GPU scaling, not a miracle.</p> <p>The second lesson is that profiler traces are necessary but not sufficient. fsdp_forward_prefetch=True produced the expected trace-level overlap, but it did not improve throughput. Optimization attempts need to be judged by end-to-end performance, not just by whether the trace looks more aesthetically pleasing.</p> <p>The third lesson is that hardware topology matters. FSDP behavior on 8 V100s is not the same as FSDP behavior on newer A100/H100 clusters. At this scale, the bottleneck shifted from memory pressure to communication bandwidth.</p> <h1 id="where-id-go-next">Where I’d go next</h1> <p>Given more time and a more emotionally supportive GPU budget, I would rerun the study on A100s with bf16 instead of V100s with fp16. V100s lack native bf16 support, and the 8-GPU interconnect topology makes FSDP all-gather behavior less favorable than on newer NVSwitch-based systems.</p> <p>I would also compare pure FSDP against tensor parallelism or hybrid parallelism. FSDP is a good default, but once all-gather communication dominates, it is worth asking whether sharding parameters alone is the right parallelization strategy.</p> <p>Finally, I would profile with Nsight Systems rather than relying only on torch.profiler. The PyTorch profiler was enough to identify the broad communication-bound pattern, but kernel-level analysis would give a cleaner view of where the time actually goes.</p> <h1 id="conclusion">Conclusion</h1> <p>This project started as a way to demystify VLM internals. The implementation part made the architecture feel less magical: image embeddings are projected, spliced into the token stream, and consumed by the LM as part of one continuous sequence.</p> <p>The scaling part was more interesting. The headline result looked like superlinear scaling, but the real finding was that the baseline was memory-constrained. Once activation checkpointing relieved that pressure, the scaling story became much more honest: FSDP scaled close to linearly from 2 to 8 V100s, then became increasingly communication-bound.</p> <p>That is probably the most useful outcome of the project. Not “I trained a tiny VLM.” Not even “I scaled it across 8 GPUs.”</p> <p>More like: I got a result that looked too good, did not trust it, profiled it, and found the boring reason underneath.</p> <p>Which, in machine learning systems, is often where the actual engineering begins.</p>]]></content><author><name></name></author><category term="engineering"/><category term="distributed-training"/><category term="fsdp"/><category term="profiling"/><category term="vlm"/><summary type="html"><![CDATA[An Engineering Case Study]]></summary></entry></feed>