<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Satsawat Natakarnkitkul</title>
    <description>The latest articles on DEV Community by Satsawat Natakarnkitkul (@net_satsawat).</description>
    <link>https://dev.to/net_satsawat</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4124667%2F2af7b5c8-2ef5-4dfa-bf75-f2f374ed4f12.jpg</url>
      <title>DEV Community: Satsawat Natakarnkitkul</title>
      <link>https://dev.to/net_satsawat</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/net_satsawat"/>
    <language>en</language>
    <item>
      <title>Continuous Batching: Why Your GPU Waits for the Slowest Request</title>
      <dc:creator>Satsawat Natakarnkitkul</dc:creator>
      <pubDate>Wed, 11 Nov 2026 16:14:18 +0000</pubDate>
      <link>https://dev.to/net_satsawat/continuous-batching-why-your-gpu-waits-for-the-slowest-request-8ch</link>
      <guid>https://dev.to/net_satsawat/continuous-batching-why-your-gpu-waits-for-the-slowest-request-8ch</guid>
      <description>&lt;p&gt;When a language model serves more than one user, the requests never finish together. One person asks a yes-or-no question and gets three tokens back. The next asks for a function and gets two thousand. If the server runs them as one fixed batch, the GPU keeps working on the short request after it is done, producing nothing, until the long one finishes.&lt;/p&gt;

&lt;p&gt;vLLM and TGI avoid this with continuous batching, and both have it on by default. Below I work out what those empty steps cost with a small example you can check by hand, and then show the vLLM metrics that tell you whether continuous batching is doing its job on your server.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkfx350gjsen8z3dhfsfc.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkfx350gjsen8z3dhfsfc.gif" alt="Animated trace of eight batch slots over sixteen decode steps. In the top pane a static batch keeps stepping requests that already finished, shown as outlined idle cells, and its busy counter falls to 67 of 128 slot-steps, 52 percent. In the bottom pane each finished request is replaced by a waiting one on the next step, shown in blue with a white notch where a new request starts, and the counter stays at 100 percent." width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Eight batch slots, sixteen decode steps. A static batch ends at 52% busy. Continuous batching gives each free slot to a waiting request and stays at 100%.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What is continuous batching?
&lt;/h2&gt;

&lt;p&gt;A GPU serving a model works in steps. Each step moves every request it holds forward by one token, and all of those requests share one read of the model's weights. That shared read is what makes serving many users at once affordable, and the group of requests sharing it is the batch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Static batching&lt;/strong&gt; keeps the group fixed. You collect some requests and run them until every one has finished, and only then start the next group. A request that finishes early cannot leave, so the GPU keeps stepping it, producing nothing, until the slowest request in the batch is done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Continuous batching&lt;/strong&gt; rebuilds the batch at every step. After each token, the server drops the requests that just finished, frees their memory, and admits waiting requests in their place. The idea comes from the &lt;a href="https://www.usenix.org/conference/osdi22/presentation/yu" rel="noopener noreferrer"&gt;Orca paper&lt;/a&gt; (OSDI 2022). As a loop, it looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# A sketch of the scheduling loop, not vLLM's actual code.&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;finished&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;       &lt;span class="c1"&gt;# drop finished requests, free their memory&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;max_num_seqs&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;cache_has_room&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                     &lt;span class="c1"&gt;# admit waiting requests into free slots&lt;/span&gt;
    &lt;span class="nf"&gt;run_one_decode_step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                         &lt;span class="c1"&gt;# one new token for every request in the batch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It combines two techniques. Ragged batching packs requests of different lengths into one sequence and uses the attention mask to keep them apart, so nothing needs padding. Dynamic scheduling is the drop-and-admit loop above. If you want the full mechanism, HuggingFace's &lt;a href="https://huggingface.co/blog/continuous_batching" rel="noopener noreferrer"&gt;continuous batching from first principles&lt;/a&gt; derives both from the tensor shapes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4aybhx9sh59r8jeiewmq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4aybhx9sh59r8jeiewmq.png" alt="Three prompts of 7, 3 and 5 tokens shown two ways. Padded into a rectangular batch they take three rows of seven slots, and six of those slots are padding cells marked pad. Packed, they sit in one row of fifteen tokens. An attention mask beside it is fifteen by fifteen, with three triangular blocks on the diagonal, one per request." width="799" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Padded, the three prompts take 21 slots and 6 are padding. Packed, they fit in 15, and the mask lets each token see only the earlier tokens of its own request.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How much does a static batch waste?
&lt;/h2&gt;

&lt;p&gt;Take the toy from the animation. Eight slots over sixteen steps give you 128 slot-steps of GPU time, and that is the capacity you pay for in that window. The eight answers come out at these lengths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3   5   8   11   4   14   6   16   tokens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They add up to 67. So the GPU does 67 slot-steps of useful work and spends the other 61 stepping requests that have already finished, because the batch cannot move on until the 16-token answer is done. Only 52% of the window does work. With continuous batching a waiting request takes each free slot on the next step, so as long as the queue is not empty, all 128 slot-steps do work.&lt;/p&gt;

&lt;p&gt;By utilization I mean batch slots doing useful work, which is different from what your GPU monitor shows. The monitor reports the card as busy through those idle steps, because the card reads the full weights at every step whether one request is live or eight. The waste shows up in tokens per second instead.&lt;/p&gt;

&lt;p&gt;How much a static batch wastes depends on how much your answer lengths vary. Keep eight requests and sixteen steps, but make the lengths 1, 1, 2, 2, 3, 3, 4 and 16. They add up to 32, so the static batch does useful work in only a quarter of its slot-steps, and seven short requests sit through twelve to fifteen empty steps each while the long one finishes. You can check both numbers yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;static_utilization&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lengths&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="s2"&gt;"""Share of a static batch's slot-steps that do useful work."""&lt;/span&gt;
    &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lengths&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                  &lt;span class="c1"&gt;# the batch runs until its longest request ends&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lengths&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lengths&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;static_utilization&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# 0.5234375  (67 of 128)&lt;/span&gt;
&lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;static_utilization&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# 0.25       (32 of 128)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Try your own traffic. Pull the output token counts from a few minutes of logs, group them into batches the size you serve, and see how far below 1.0 the number lands.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does the 23x come from?
&lt;/h2&gt;

&lt;p&gt;You will see continuous batching quoted with big multipliers like 23x. Most of that size comes from the baseline it was compared with.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.anyscale.com/blog/continuous-batching-llm-inference" rel="noopener noreferrer"&gt;Anyscale&lt;/a&gt; ran OPT-13B on a single A100. Continuous batching alone gave up to 8x the throughput of naive static batching, and up to 23x once vLLM's PagedAttention was added, the memory system that fits more requests on the card. &lt;a href="https://vllm.ai/blog/2023-06-20-vllm" rel="noopener noreferrer"&gt;vLLM's launch post&lt;/a&gt; reports, with one completion per request, 14x to 24x the throughput of HuggingFace Transformers and only 2.2x to 2.5x that of Text Generation Inference (TGI). The 3.3x to 3.5x over TGI in the post's headline comes from a separate run with three completions per request.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgis4bhj0em8y3zkmur38.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgis4bhj0em8y3zkmur38.png" alt="Four horizontal bars drawn to the same scale. Continuous batching alone versus naive static batching, 8x. Continuous batching plus PagedAttention versus naive static batching, 23x. vLLM versus HuggingFace Transformers, 14x to 24x. vLLM versus TGI, which already does continuous batching, 2.2x to 2.5x, the only green bar and by far the shortest." width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The four claims drawn to scale. Only the green bar compares two engines that both already do continuous batching.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;TGI already does continuous batching, so whatever vLLM gains over TGI comes from PagedAttention rather than from the scheduler. The multiples above 20 appear only when the baseline does no continuous batching at all. So continuous batching on its own gives a big gain over a naive loop and very little over an engine that already does it, and most of the rest of the 23x comes from better memory management.&lt;/p&gt;

&lt;p&gt;These numbers come from other people's models and hardware, so I would not use them to predict your own gain. What does apply to your server is the pattern from the toy example: the more your output lengths vary, the more continuous batching saves you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does it help latency too?
&lt;/h2&gt;

&lt;p&gt;Yes, which is unusual, because batching normally trades latency for throughput. Anyscale's post says so in its title. Under static batching, a new request cannot start until the whole current batch has finished, so it may wait behind two thousand tokens of someone else's answer. Under continuous batching it starts as soon as any slot frees up, usually a few steps later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should you check on your own server?
&lt;/h2&gt;

&lt;p&gt;You are probably running continuous batching already, so the job is to check that it works. vLLM exposes the numbers you need on the same port as the API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -s localhost:8000/metrics \
  &lt;span class="o"&gt;|&lt;/span&gt; grep -E &lt;span class="s1"&gt;'^vllm:(num_requests_running|num_requests_waiting|num_preemptions_total)'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Make sure you are using it at all. vLLM and TGI do it by default. A home-grown generation loop, or code that collects requests and waits for the whole group to finish, does not. If that is you, switching to an engine that does it will raise throughput more than any kernel or quantization change.&lt;/li&gt;
&lt;li&gt;Compare the running batch with the cap. &lt;code&gt;--max-num-seqs&lt;/code&gt; is only the most requests vLLM may run at once. The batch you get under load is &lt;code&gt;vllm:num_requests_running&lt;/code&gt;. Under real traffic it should sit near the cap. If it sits far below the cap while requests wait, a higher cap will not help, because something else limits the batch, almost always the KV cache memory that decides how many requests fit on the card.&lt;/li&gt;
&lt;li&gt;Watch preemptions. When the cache fills up, the scheduler pulls a running request out of the batch to free its memory and puts it back in the queue to finish later. Nothing crashes, but &lt;code&gt;vllm:num_preemptions_total&lt;/code&gt; goes up. If it climbs at a load you used to handle, you are short of memory. If it stays flat while latency still gets worse, the time is going to queueing and to slower steps.&lt;/li&gt;
&lt;li&gt;Watch throughput when output lengths spread out. If tokens per second drop exactly when answers get uneven, something in your stack still keeps finished requests in the batch.&lt;/li&gt;
&lt;li&gt;Change one setting at a time. &lt;code&gt;--max-num-seqs&lt;/code&gt; interacts with KV cache capacity and with the per-step token budget, and if you move two at once you cannot tell which one did what.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  My recommendation
&lt;/h2&gt;

&lt;p&gt;Run the &lt;code&gt;curl&lt;/code&gt; command above during your busiest hour, before you change any setting. That one reading tells you where to look. If the running batch stays near the cap, continuous batching is doing its job and your next limit is somewhere else. If it stays well below the cap while requests are waiting, spend your time on KV cache memory rather than on the scheduler.&lt;/p&gt;

&lt;p&gt;The sums in this post, and the rest of the serving arithmetic I write about, are in a small MIT-licensed Python package, &lt;a href="https://github.com/netsatsawat/llm-inference-arithmetic" rel="noopener noreferrer"&gt;llm-inference-arithmetic&lt;/a&gt;, with browser calculators if you would rather not install anything.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>machinelearning</category>
      <category>performance</category>
      <category>gpu</category>
    </item>
  </channel>
</rss>
