<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Runcrate]]></title><description><![CDATA[Runcrate]]></description><link>https://runcrate.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6add24f6838a42a2d61bcf03/13444158-5303-4dca-82a9-f035f1a44ce0.png</url><title>Runcrate</title><link>https://runcrate.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 02 Nov 2026 15:59:52 GMT</lastBuildDate><atom:link href="https://runcrate.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[4 GPUs, 2x speed: why PyTorch DDP stalled on a PCIe-only 4x RTX A6000, and two flags that took it to 3.6x]]></title><description><![CDATA[Plain DDP on a rented 4x RTX A6000 gave 2x. Measured all-reduce bandwidth, then bf16 gradients and accumulation took it to 3.6x. Commands inside.]]></description><link>https://runcrate.hashnode.dev/4-gpus-2x-speed-why-pytorch-ddp-stalled-on-a-pcie-only-4x-rtx-a6000-and-two-flags-that-took-it-to-3-6x</link><guid isPermaLink="true">https://runcrate.hashnode.dev/4-gpus-2x-speed-why-pytorch-ddp-stalled-on-a-pcie-only-4x-rtx-a6000-and-two-flags-that-took-it-to-3-6x</guid><dc:creator><![CDATA[Runcrate]]></dc:creator><pubDate>Mon, 26 Oct 2026 09:24:40 GMT</pubDate><content:encoded><![CDATA[<p>Four GPUs should train about four times faster than one. On a rented 4x RTX A6000 machine this morning (2026-10-26), plain PyTorch DDP gave me 2x. This is the notebook of finding out why, and the two flags that took it to 3.6x.</p>
<h2>Setup</h2>
<ul>
<li><strong>Machine:</strong> one Runcrate 4x RTX A6000 (48 GB each) VM, US Midwest, $2.64/hour for the whole machine at launch time. Two separate machines of the same type over the morning: the first for the baseline, the second for the fixes.</li>
<li><strong>Topology</strong> (<code>nvidia-smi topo -m</code>): every GPU pair is <code>PHB</code>, i.e. GPU to GPU traffic crosses the CPU's PCIe host bridge. No NVLink. PCIe gen 3 x16 per card.</li>
<li><strong>Model:</strong> a GPT-2 style decoder written in plain PyTorch, two sizes: 124M parameters (12 layers, 768 wide) and 355M (24 layers, 1024 wide). Random tokens, sequence 1024, micro-batch 8 per GPU, bf16 autocast, fused AdamW.</li>
<li><strong>Software:</strong> torch 2.8.0+cu128 (first machine) and 2.11.0+cu128 (second), NCCL as shipped in the wheel.</li>
<li><strong>Metric:</strong> tokens/s from the median step time over 40 steps after 10 warm-up steps. Weak scaling: each GPU always gets its own micro-batch of 8.</li>
</ul>
<h2>The commands</h2>
<pre><code class="language-bash"># baseline: 1, 2 and 4 processes on the same machine
for N in 1 2 4; do torchrun --standalone --nproc_per_node $N train_ddp.py --size small; done

# all-reduce bandwidth, nccl-tests style (bus bandwidth = algbw x 2(n-1)/n)
torchrun --standalone --nproc_per_node 4 allreduce.py

# the fixes
torchrun --standalone --nproc_per_node 4 train_ddp.py --size small --hook bf16            # bf16 gradient compression
torchrun --standalone --nproc_per_node 4 train_ddp.py --size small --accum 4              # all-reduce every 4 micro-batches
torchrun --standalone --nproc_per_node 4 train_ddp.py --size small --accum 4 --hook bf16  # both
</code></pre>
<p>The two fixes are a few lines each in the training loop:</p>
<pre><code class="language-python">from torch.distributed.algorithms.ddp_comm_hooks import default_hooks
ddp = DDP(model, device_ids=[local_rank], gradient_as_bucket_view=True)
ddp.register_comm_hook(None, default_hooks.bf16_compress_hook)   # send gradients as bf16

for k in range(accum):                                            # gradient accumulation
    ctx = ddp.no_sync() if k &lt; accum - 1 else contextlib.nullcontext()
    with ctx, torch.autocast("cuda", dtype=torch.bfloat16):
        loss = model_loss(ddp, next_batch()) / accum
        loss.backward()
opt.step(); opt.zero_grad(set_to_none=True)
</code></pre>
<h2>Baseline: 2x from 4 GPUs</h2>
<table>
<thead>
<tr>
<th>Model</th>
<th>GPUs</th>
<th>Tokens/s</th>
<th>Speed-up vs 1 GPU</th>
</tr>
</thead>
<tbody><tr>
<td>124M</td>
<td>1</td>
<td>61,907</td>
<td>1.00x</td>
</tr>
<tr>
<td>124M</td>
<td>2</td>
<td>77,287</td>
<td>1.25x</td>
</tr>
<tr>
<td>124M</td>
<td>4</td>
<td>127,682</td>
<td>2.06x</td>
</tr>
<tr>
<td>355M</td>
<td>1</td>
<td>25,762</td>
<td>1.00x</td>
</tr>
<tr>
<td>355M</td>
<td>2</td>
<td>30,431</td>
<td>1.18x</td>
</tr>
<tr>
<td>355M</td>
<td>4</td>
<td>48,611</td>
<td>1.89x</td>
</tr>
</tbody></table>
<p>The all-reduce explains it. NCCL bus bandwidth between the four cards was <strong>4.3 GB/s</strong> (2 GPUs: 3.9 GB/s), flat from 8 MB to 1 GB messages. DDP all-reduces fp32 gradients: about 500 MB per step for the 124M model. At 2.9 GB/s of algorithm bandwidth that is roughly 170 ms of communication against a 130 ms compute step, and 4 GPUs measured about 255 ms per step. The GPUs spend more time talking than computing.</p>
<h2>The fixes, on 4 GPUs</h2>
<table>
<thead>
<tr>
<th>Model</th>
<th>Variant</th>
<th>Tokens/s</th>
<th>Speed-up vs 1 GPU (same variant)</th>
<th>$ per 1B training tokens</th>
</tr>
</thead>
<tbody><tr>
<td>124M</td>
<td>1 GPU</td>
<td>64,912</td>
<td>1.00x</td>
<td>2.82 (one GPU's share, $0.66/h)</td>
</tr>
<tr>
<td>124M</td>
<td>4 GPUs, plain DDP</td>
<td>129,032</td>
<td>1.99x</td>
<td>5.68</td>
</tr>
<tr>
<td>124M</td>
<td>4 GPUs, bf16 gradients</td>
<td>172,144</td>
<td>2.65x</td>
<td>4.26</td>
</tr>
<tr>
<td>124M</td>
<td>4 GPUs, accumulate 4</td>
<td>211,834</td>
<td>3.20x</td>
<td>3.46</td>
</tr>
<tr>
<td>124M</td>
<td>4 GPUs, both</td>
<td><strong>234,175</strong></td>
<td><strong>3.54x</strong></td>
<td>3.13</td>
</tr>
<tr>
<td>355M</td>
<td>1 GPU</td>
<td>27,109</td>
<td>1.00x</td>
<td>6.76</td>
</tr>
<tr>
<td>355M</td>
<td>4 GPUs, plain DDP</td>
<td>51,711</td>
<td>1.91x</td>
<td>14.18</td>
</tr>
<tr>
<td>355M</td>
<td>4 GPUs, bf16 gradients</td>
<td>73,653</td>
<td>2.72x</td>
<td>9.96</td>
</tr>
<tr>
<td>355M</td>
<td>4 GPUs, accumulate 4</td>
<td>86,507</td>
<td>3.10x</td>
<td>8.48</td>
</tr>
<tr>
<td>355M</td>
<td>4 GPUs, both</td>
<td><strong>99,152</strong></td>
<td><strong>3.66x</strong></td>
<td>7.40</td>
</tr>
</tbody></table>
<p>(1-GPU tokens/s with accumulation 4: 66,189 and 27,893, so the "both" rows are 88% and 89% scaling efficiency against the same recipe on one GPU.)</p>
<h2>What surprised me</h2>
<ol>
<li><strong>The interconnect, not the GPU, set the speed.</strong> 4.3 GB/s of all-reduce bandwidth is a fraction of what PCIe gen 3 x16 can carry. With every pair going through the host bridge, plain DDP on four cards only doubled throughput.</li>
<li><strong>Forcing peer-to-peer made it worse.</strong> <code>NCCL_P2P_LEVEL=SYS</code> (use P2P even across the host bridge) halved the all-reduce bandwidth to 2.3 GB/s and dropped 124M training from 129k to 80k tokens/s. NCCL's default choice was right; don't override it on a PHB machine.</li>
<li><strong>Two cheap changes recovered most of it.</strong> bf16 gradient compression halves the bytes; accumulating 4 micro-batches per all-reduce cuts the number of all-reduces by 4. Together: 3.5x to 3.7x on four GPUs. Accumulation changes your effective batch size (32 per GPU here), so retune the learning rate if you adopt it.</li>
<li><strong>Per token, one GPU still won.</strong> Even with both fixes, a token cost about 10% more on four GPUs than on one, because four GPUs at 88% efficiency is still four GPUs. Rent the 4x machine when wall-clock time matters (a 3.5x shorter run), not to save money.</li>
<li><strong>The bigger model scaled slightly better,</strong> as expected: more compute per byte of gradient.</li>
</ol>
<h2>Caveats</h2>
<ul>
<li>Random tokens and a synthetic model: this measures throughput, not convergence. bf16 gradient compression slightly changes numerics.</li>
<li>Two machines of the same type, one run per configuration. Topology varies between machines; run <code>nvidia-smi topo -m</code> and a 30-second all-reduce test before a long job.</li>
<li>Machines with NVLink behave completely differently; this is a PCIe-only result.</li>
<li>Prices are on-demand at launch time on 2026-10-26.</li>
</ul>
<h2>Reproduce it</h2>
<ol>
<li>Launch a 4x GPU instance through <code>POST /api/v1/instances</code> with <code>max_runtime_minutes</code> set.</li>
<li><code>uv venv /opt/env &amp;&amp; . /opt/env/bin/activate &amp;&amp; uv pip install torch numpy --torch-backend=cu128</code>.</li>
<li>Copy <code>train_ddp.py</code> and <code>allreduce.py</code> (about 100 lines together; the core is the snippet above) and run the torchrun commands.</li>
<li>Delete the instance.</li>
</ol>
<p>Both 4x machines together, including setup and every configuration above, were billed $0.92.</p>
<p>If you want to try it on your own model: <a href="https://www.runcrate.ai/?utm_source=hashnode&amp;utm_medium=post&amp;utm_campaign=ddp-scaling">runcrate.ai</a></p>
<p><em>Written by the Runcrate team, with AI assistance. The numbers come from the runs described above; raw logs are kept with the post.</em></p>
]]></content:encoded></item><item><title><![CDATA[How long until a rented GPU runs CUDA? Launch to first matmul on six GPU types, timed]]></title><description><![CDATA[Six single-GPU machines launched through an API, timed from create call to first SSH and first CUDA matmul. 3 to 12 minutes, and why.]]></description><link>https://runcrate.hashnode.dev/how-long-until-a-rented-gpu-runs-cuda-launch-to-first-matmul-on-six-gpu-types-timed</link><guid isPermaLink="true">https://runcrate.hashnode.dev/how-long-until-a-rented-gpu-runs-cuda-launch-to-first-matmul-on-six-gpu-types-timed</guid><dc:creator><![CDATA[Runcrate]]></dc:creator><pubDate>Mon, 26 Oct 2026 09:21:46 GMT</pubDate><content:encoded><![CDATA[<p>"How long until my GPU is ready?" is the question I care about most when I rent one for a short job, and it is the number nobody publishes. So this morning (2026-10-26) I launched single-GPU machines of six types on Runcrate through the API, one after another in one batch, and timed every step until a real CUDA matmul finished. Then I deleted each machine the second it was done.</p>
<h2>What I measured</h2>
<p>For each machine, seconds since the create call returned:</p>
<ul>
<li><strong>API running:</strong> the instance API reports <code>running</code> and hands out the IP.</li>
<li><strong>First SSH:</strong> my script's first successful <code>ssh root@ip echo ok</code> (it retries every 2 s once it has the IP).</li>
<li><strong>First CUDA op:</strong> after SSH, install <code>uv</code>, create a venv, <code>uv pip install torch --torch-backend=cu128</code>, then multiply two 4096x4096 matrices on the GPU and synchronize.</li>
</ul>
<p>I also read two clocks on the machine itself, because they tell you where the time goes: kernel boot (<code>uptime -s</code>) and when <code>ssh.service</code> came up (<code>systemctl show ssh -p ActiveEnterTimestamp</code>).</p>
<h2>The commands</h2>
<pre><code class="language-bash"># 1. launch (instance_type_id from GET /api/v1/instances/types)
curl -s https://www.runcrate.ai/api/v1/instances \
  -H "Authorization: Bearer $RUNCRATE_API_KEY" -H "Content-Type: application/json" \
  -d '{"name":"cold-l40s","instance_type_id":"&lt;id&gt;","ssh_key_id":"&lt;key id&gt;","max_runtime_minutes":30}'

# 2. poll every 10 s until status is running and ip is set
curl -s https://www.runcrate.ai/api/v1/instances/&lt;id&gt; -H "Authorization: Bearer $RUNCRATE_API_KEY"

# 3. on the machine
curl -LsSf https://astral.sh/uv/install.sh | sh &amp;&amp; export PATH=$HOME/.local/bin:$PATH
uv venv -q --python 3.12 /opt/env &amp;&amp; . /opt/env/bin/activate
uv pip install -q torch --torch-backend=cu128
python -c "import torch,time;t=time.time();x=torch.randn(4096,4096,device='cuda');y=x@x;torch.cuda.synchronize();print(time.time()-t)"

# 4. delete
curl -s -X DELETE https://www.runcrate.ai/api/v1/instances/&lt;id&gt; -H "Authorization: Bearer $RUNCRATE_API_KEY"
</code></pre>
<h2>Results</h2>
<p>All VMs, Ubuntu image, one GPU each, launched within 4 seconds of each other at 09:02 UTC.</p>
<table>
<thead>
<tr>
<th>Runcrate GPU</th>
<th>Region</th>
<th>$/hour</th>
<th>API running</th>
<th>First SSH</th>
<th>First CUDA op</th>
<th>Billed for the whole run</th>
</tr>
</thead>
<tbody><tr>
<td>L40S</td>
<td>Des Moines, US</td>
<td>1.14</td>
<td>151 s</td>
<td>164 s</td>
<td><strong>186 s</strong></td>
<td>$0.010</td>
</tr>
<tr>
<td>A100 80GB</td>
<td>Montreal, CA</td>
<td>1.73</td>
<td>303 s</td>
<td>319 s</td>
<td><strong>337 s</strong></td>
<td>$0.016</td>
</tr>
<tr>
<td>RTX A4000</td>
<td>New York, US</td>
<td>1.04</td>
<td>452 s</td>
<td>463 s</td>
<td><strong>482 s</strong></td>
<td>$0.008</td>
</tr>
<tr>
<td>V100 16GB</td>
<td>New York, US</td>
<td>0.36</td>
<td>611 s</td>
<td>625 s</td>
<td><strong>673 s</strong> (see below)</td>
<td>$0.006</td>
</tr>
<tr>
<td>RTX 6000 Ada</td>
<td>US Midwest</td>
<td>1.08</td>
<td>620 s</td>
<td>635 s</td>
<td><strong>670 s</strong></td>
<td>$0.014</td>
</tr>
<tr>
<td>RTX A6000</td>
<td>US Midwest</td>
<td>0.66</td>
<td>629 s</td>
<td>647 s</td>
<td><strong>707 s</strong></td>
<td>$0.014</td>
</tr>
</tbody></table>
<p>And from the machines' own clocks, same runs:</p>
<table>
<thead>
<tr>
<th>Runcrate GPU</th>
<th>Kernel booted</th>
<th>ssh.service up</th>
</tr>
</thead>
<tbody><tr>
<td>L40S</td>
<td>65 s</td>
<td>149 s</td>
</tr>
<tr>
<td>A100 80GB</td>
<td>116 s</td>
<td>293 s</td>
</tr>
<tr>
<td>RTX A4000</td>
<td>143 s</td>
<td>455 s</td>
</tr>
<tr>
<td>V100 16GB</td>
<td>30 s</td>
<td>61 s</td>
</tr>
<tr>
<td>RTX 6000 Ada</td>
<td>76 s</td>
<td>97 s</td>
</tr>
<tr>
<td>RTX A6000</td>
<td>95 s</td>
<td>121 s</td>
</tr>
</tbody></table>
<p>The torch step on its own (venv + wheel install + first matmul): 10 to 48 s for the install, 0.2 to 1.0 s for the first matmul, which is mostly CUDA context creation.</p>
<h2>What surprised me</h2>
<ol>
<li><strong>The spread is 4x, and it is not about the GPU.</strong> The L40S was doing math in about 3 minutes, the A6000 in almost 12. Same API, same image, same script. Cold start depends on where the machine comes from far more than on which card is in it, so if you launch often, measure the specific region you use.</li>
<li><strong>On three types, the machine was ready long before the API said so.</strong> The V100, RTX 6000 Ada and A6000 booted and had sshd listening in 1 to 2 minutes, but the API reported them running about 8 minutes later. Nothing in my script could have used them earlier, because the IP arrives with <code>running</code>. The upside: billing starts at <code>running</code>, which is why the bills in the table are a cent or two.</li>
<li><strong>The newest PyTorch wheels no longer run on a V100.</strong> <code>uv pip install torch --torch-backend=cu128</code> resolved to torch 2.11.0, which warns that it was built for compute capability 7.5 and up, and the matmul then failed with <code>cudaErrorNoKernelImageForDevice</code>. The V100 is 7.0. On an earlier V100 run the same command had resolved to torch 2.8.0, which worked. If you rent Volta cards, pin torch (<code>torch==2.8.*</code> worked here) instead of taking the latest.</li>
<li><strong>Not every create call works.</strong> Over the day I made 18 create calls. Two (an RTX 4090 and an RTX 5090 VM in the same region) came back within 7 seconds with <code>500 provider_unavailable</code>, and one type was gone between listing it and launching it (<code>400 ... no longer available</code>). Both failures are fast and cost nothing, so the right client behaviour is: read <code>/instances/types</code> right before launching, and fall back to the next option on a 4xx/5xx.</li>
<li><strong>A container is a different animal.</strong> In an earlier batch I launched an RTX 5090 as a container instead of a VM: its clock said it started 4 seconds after the create call. If startup time matters more than a full VM, check the <code>deployment_type</code> column.</li>
<li><strong>My own polling made my first batch useless.</strong> I first polled nine machines every 2 seconds, hit the API's rate limit (HTTP 429) for several minutes, and the "running" times from that batch were wrong by minutes. This table is from a second batch polling every 10 seconds. Poll slowly.</li>
</ol>
<h2>Caveats</h2>
<ul>
<li>One launch per GPU type in this table, at one time of day. Cold start depends on what is in stock at that moment; treat it as a sample, not a promise.</li>
<li>"First CUDA op" includes downloading a 900 MB torch wheel. A pre-built image or a volume with a cached venv removes most of the 10 to 48 s.</li>
<li>The prices are the on-demand prices at launch time and move daily.</li>
</ul>
<h2>Reproduce it</h2>
<ol>
<li>Create an API key, add an SSH key (<code>POST /api/v1/ssh-keys</code>).</li>
<li>For each type you care about: launch with the curl above, poll every 10 s or slower, SSH in as soon as there is an IP, run the torch block, delete.</li>
<li>Read <code>uptime -s</code> and <code>systemctl show ssh -p ActiveEnterTimestamp</code> on the machine if you want to know where the time went.</li>
</ol>
<p>The six machines in the table were billed $0.07 in total.</p>
<p>If you want to time it yourself: <a href="https://www.runcrate.ai/?utm_source=hashnode&amp;utm_medium=post&amp;utm_campaign=gpu-cold-start">runcrate.ai</a></p>
<p><em>Written by the Runcrate team, with AI assistance. The numbers come from the runs described above; raw logs are kept with the post.</em></p>
]]></content:encoded></item><item><title><![CDATA[vLLM on three GPUs: Qwen2.5-7B tokens per second and cost per 1M tokens on RTX A6000, L40S and H100]]></title><description><![CDATA[Same model, same vLLM benchmark, three rented GPUs. Cost per 1M output tokens at concurrency 1 to 64, with the commands to rerun it.]]></description><link>https://runcrate.hashnode.dev/vllm-on-three-gpus-qwen2-5-7b-tokens-per-second-and-cost-per-1m-tokens-on-rtx-a6000-l40s-and-h100</link><guid isPermaLink="true">https://runcrate.hashnode.dev/vllm-on-three-gpus-qwen2-5-7b-tokens-per-second-and-cost-per-1m-tokens-on-rtx-a6000-l40s-and-h100</guid><category><![CDATA[vLLM]]></category><dc:creator><![CDATA[Runcrate]]></dc:creator><pubDate>Mon, 26 Oct 2026 09:19:03 GMT</pubDate><content:encoded><![CDATA[<p>I wanted one number before picking a GPU for a small self-hosted model: what does a million generated tokens cost on each card, once the server is actually busy? Spec sheets don't answer that, so I rented three single-GPU machines on Runcrate this morning (2026-10-26), ran the same vLLM benchmark on each, and terminated them as soon as the numbers were on disk.</p>
<h2>Setup</h2>
<ul>
<li><strong>Model:</strong> Qwen/Qwen2.5-7B-Instruct, bf16, no quantization.</li>
<li><strong>Server:</strong> vLLM 0.11.0 (torch 2.8.0+cu128, transformers 4.57.6), <code>--max-model-len 4096 --gpu-memory-utilization 0.90</code>, everything else default.</li>
<li><strong>Load:</strong> <code>vllm bench serve</code> with random prompts, 512 tokens in and exactly 256 tokens out (<code>--ignore-eos</code>), at concurrency 1, 8, 32 and 64 (16, 64, 192 and 320 prompts), seed 42. The client ran on the same machine as the server, so there is no network in these numbers.</li>
<li><strong>Machines</strong> (on-demand price at launch time):</li>
</ul>
<table>
<thead>
<tr>
<th>Runcrate GPU</th>
<th>VRAM</th>
<th>Region</th>
<th>$/hour</th>
</tr>
</thead>
<tbody><tr>
<td>RTX A6000</td>
<td>48 GB</td>
<td>Des Moines, US</td>
<td>0.74</td>
</tr>
<tr>
<td>L40S</td>
<td>48 GB</td>
<td>Kansas City, US</td>
<td>1.14</td>
</tr>
<tr>
<td>H100 PCIe</td>
<td>80 GB</td>
<td>Montreal, CA</td>
<td>3.55</td>
</tr>
</tbody></table>
<p>Cost per 1M output tokens below is simply <code>hourly price / (output tokens per second x 3600) x 1,000,000</code>. It counts only generated tokens; the 512 prompt tokens per request are free riders in this math.</p>
<h2>The commands</h2>
<p>Launch (one call per GPU; the <code>instance_type_id</code> comes from <code>GET /api/v1/instances/types</code>):</p>
<pre><code class="language-bash">curl -s https://www.runcrate.ai/api/v1/instances \
  -H "Authorization: Bearer $RUNCRATE_API_KEY" -H "Content-Type: application/json" \
  -d '{"name":"vllm-a6000","instance_type_id":"&lt;id from /instances/types&gt;",
       "ssh_key_id":"&lt;your key id&gt;","max_runtime_minutes":45}'
</code></pre>
<p><code>max_runtime_minutes</code> is the safety net: the machine stops itself even if my script dies. Then, on the machine:</p>
<pre><code class="language-bash">curl -LsSf https://astral.sh/uv/install.sh | sh &amp;&amp; export PATH=$HOME/.local/bin:$PATH
uv venv --python 3.12 /opt/env &amp;&amp; . /opt/env/bin/activate
uv pip install vllm==0.11.0 'transformers&lt;4.58' --torch-backend=cu128

vllm serve Qwen/Qwen2.5-7B-Instruct --dtype bfloat16 --max-model-len 4096 \
  --gpu-memory-utilization 0.90 --port 8000 &amp;

for C in 1 8 32 64; do
  case $C in 1) N=16;; 8) N=64;; 32) N=192;; 64) N=320;; esac
  vllm bench serve --backend vllm --model Qwen/Qwen2.5-7B-Instruct \
    --dataset-name random --random-input-len 512 --random-output-len 256 --ignore-eos \
    --num-prompts $N --max-concurrency $C --seed 42 \
    --save-result --result-dir results --result-filename bench_c$C.json
done
</code></pre>
<p>Then <code>DELETE /api/v1/instances/&lt;id&gt;</code> and a check that the list is empty.</p>
<h2>Results</h2>
<table>
<thead>
<tr>
<th>GPU</th>
<th>Concurrency</th>
<th>Output tok/s</th>
<th>Per-user tok/s</th>
<th>Median TTFT</th>
<th>p99 TTFT</th>
<th>$ per 1M output tokens</th>
</tr>
</thead>
<tbody><tr>
<td>RTX A6000</td>
<td>1</td>
<td>42</td>
<td>43</td>
<td>82 ms</td>
<td>85 ms</td>
<td>4.86</td>
</tr>
<tr>
<td>RTX A6000</td>
<td>8</td>
<td>307</td>
<td>40</td>
<td>289 ms</td>
<td>529 ms</td>
<td>0.67</td>
</tr>
<tr>
<td>RTX A6000</td>
<td>32</td>
<td>1,012</td>
<td>32</td>
<td>479 ms</td>
<td>1,940 ms</td>
<td>0.20</td>
</tr>
<tr>
<td>RTX A6000</td>
<td>64</td>
<td>1,727</td>
<td>32</td>
<td>259 ms</td>
<td>3,347 ms</td>
<td><strong>0.12</strong></td>
</tr>
<tr>
<td>L40S</td>
<td>1</td>
<td>48</td>
<td>49</td>
<td>48 ms</td>
<td>49 ms</td>
<td>6.56</td>
</tr>
<tr>
<td>L40S</td>
<td>8</td>
<td>354</td>
<td>45</td>
<td>161 ms</td>
<td>299 ms</td>
<td>0.89</td>
</tr>
<tr>
<td>L40S</td>
<td>32</td>
<td>1,082</td>
<td>34</td>
<td>279 ms</td>
<td>1,050 ms</td>
<td>0.29</td>
</tr>
<tr>
<td>L40S</td>
<td>64</td>
<td>1,887</td>
<td>33</td>
<td>146 ms</td>
<td>2,169 ms</td>
<td><strong>0.17</strong></td>
</tr>
<tr>
<td>H100 PCIe</td>
<td>1</td>
<td>104</td>
<td>105</td>
<td>25 ms</td>
<td>26 ms</td>
<td>9.47</td>
</tr>
<tr>
<td>H100 PCIe</td>
<td>8</td>
<td>776</td>
<td>100</td>
<td>62 ms</td>
<td>125 ms</td>
<td>1.27</td>
</tr>
<tr>
<td>H100 PCIe</td>
<td>32</td>
<td>2,566</td>
<td>87</td>
<td>233 ms</td>
<td>410 ms</td>
<td>0.38</td>
</tr>
<tr>
<td>H100 PCIe</td>
<td>64</td>
<td>4,515</td>
<td>79</td>
<td>101 ms</td>
<td>960 ms</td>
<td><strong>0.22</strong></td>
</tr>
</tbody></table>
<p>"Per-user tok/s" is 1000 / median time per output token, i.e. how fast one stream feels to the person reading it.</p>
<p>How long each machine took before it could serve anything (measured on the machine):</p>
<table>
<thead>
<tr>
<th>GPU</th>
<th>vLLM install</th>
<th>Model download (15 GB)</th>
<th><code>vllm serve</code> until ready</th>
<th>First chat reply</th>
</tr>
</thead>
<tbody><tr>
<td>RTX A6000</td>
<td>29 s</td>
<td>103 s</td>
<td>114 s</td>
<td>1.22 s</td>
</tr>
<tr>
<td>L40S</td>
<td>7 s</td>
<td>78 s</td>
<td>67 s</td>
<td>0.85 s</td>
</tr>
<tr>
<td>H100 PCIe</td>
<td>4 s</td>
<td>13 s</td>
<td>54 s</td>
<td>0.56 s</td>
</tr>
</tbody></table>
<h2>What surprised me</h2>
<ol>
<li><strong>The L40S is barely faster than the A6000 at decoding.</strong> It has roughly twice the bf16 compute on paper, but single-stream decode on a 7B model is limited by memory bandwidth, and the two cards are close there. 48 vs 42 tokens/s at concurrency 1, 1,887 vs 1,727 at 64. Per dollar, the A6000 wins at every load level I tried.</li>
<li><strong>The H100 is not the per-token deal, it is the latency deal.</strong> It produced 2.6x the A6000's throughput at concurrency 64 for 4.8x the hourly price. But every user still saw 79 tokens/s at that load, against 32 on the other two cards, and the p99 time to first token stayed under a second. If your product is a chat UI with real people waiting, that is what you are paying for.</li>
<li><strong>At concurrency 1, every GPU is expensive.</strong> $4.86 to $9.47 per million tokens. Batching is where the money is: going from 1 to 64 concurrent requests cut the cost per token by about 40x on all three cards. If your traffic can't keep the batch full, a smaller, older card idles more cheaply.</li>
<li><strong>p99 TTFT at concurrency 64 on the A6000 was 3.3 s,</strong> while the median was 259 ms. Some requests wait for a whole batch slot. Look at tail latency before you take the cost-per-token number at face value.</li>
<li><strong>Setup time varied more than benchmark time.</strong> The 15 GB model download took 13 s on one machine and 103 s on another. For short experiments, that download is a real part of the bill; put the weights on a volume if you will start the server more than once.</li>
</ol>
<h2>Caveats</h2>
<ul>
<li>One run per GPU, on one morning. Treat differences under about 10% as noise.</li>
<li>Random prompts, fixed 512/256 shape. Real chat traffic has prefix reuse and varied lengths; prefix caching would help all three cards.</li>
<li>These are PCIe cards, one per machine. No tensor parallelism, no FP8, no speculative decoding. An FP8 checkpoint would likely change the H100 math more than the others.</li>
<li>Prices are the on-demand prices at launch time on 2026-10-26 and move daily.</li>
</ul>
<h2>Reproduce it</h2>
<ol>
<li>Create an API key and add an SSH key (<code>POST /api/v1/ssh-keys</code>).</li>
<li>Pick an instance type from <code>GET /api/v1/instances/types</code> and launch with the curl above, <code>max_runtime_minutes</code> set.</li>
<li>Run the setup and benchmark block, copy <code>results/</code> back, delete the instance.</li>
</ol>
<p>The three machines, including setup time, were billed $1.07 together ($0.15 for the A6000, $0.29 for the L40S, $0.64 for the H100).</p>
<p>If you want to run it yourself: <a href="https://www.runcrate.ai/?utm_source=hashnode&amp;utm_medium=post&amp;utm_campaign=vllm-tokens-per-dollar">runcrate.ai</a></p>
<p><em>Written by the Runcrate team, with AI assistance. The numbers come from the run described above; raw logs are kept with the post.</em></p>
]]></content:encoded></item><item><title><![CDATA[Embed your docs on a rented GPU or call an embeddings API? We measured both with bge-m3]]></title><description><![CDATA[527 chunks, the same model both ways, real bills. The API won for our docs; the GPU wins past about 630k tokens.]]></description><link>https://runcrate.hashnode.dev/embed-your-docs-on-a-rented-gpu-or-call-an-embeddings-api-we-measured-both-with-bge-m3</link><guid isPermaLink="true">https://runcrate.hashnode.dev/embed-your-docs-on-a-rented-gpu-or-call-an-embeddings-api-we-measured-both-with-bge-m3</guid><category><![CDATA[RAG ]]></category><category><![CDATA[#Embeddings]]></category><category><![CDATA[GPU]]></category><category><![CDATA[Python]]></category><category><![CDATA[Machine Learning]]></category><dc:creator><![CDATA[Runcrate]]></dc:creator><pubDate>Sun, 25 Oct 2026 13:03:11 GMT</pubDate><content:encoded><![CDATA[<p><em>Written by the Runcrate team, using our own API.</em></p>
<p>Every RAG project hits the same fork: self-host the embedding model on a GPU, or pay an embeddings API per token. The usual arguments are about privacy and control. This post is about the boring part, time and money, measured on one real corpus with the same model on both sides.</p>
<h2>The corpus and the model</h2>
<ul>
<li><strong>Corpus:</strong> Runcrate's own documentation, 146 MDX pages, split on paragraph boundaries into 527 chunks of up to about 1,200 characters (501,843 characters in total).</li>
<li><strong>Model:</strong> <code>BAAI/bge-m3</code> (1024-dimensional) on both sides, so quality is not a variable.</li>
<li><strong>GPU side:</strong> one NVIDIA RTX A4000 (16 GB) as a Runcrate job at $0.20/hour (the live price on 2026-10-24 at 21:35 UTC), sentence-transformers in fp16. A job runs one command on a fresh machine and stops billing when the command exits.</li>
<li><strong>API side:</strong> Runcrate's OpenAI-compatible <code>/v1/embeddings</code> with the same model, listed at $0.01 per 1M input tokens in <code>GET /v1/models</code> at the time of the run.</li>
</ul>
<p>Both sides ran on our own platform. That is convenient for a fair test (same model, same day, same bill format), but keep it in mind. Prices on 2026-10-24 at 22:13 UTC, re-read for this post from the live feed: the A4000 is listed from $0.20 to $1.04 per hour depending on region (<code>/api/gpu-pricing</code>, see <a href="https://www.runcrate.ai/pricing?utm_source=hashnode&amp;utm_medium=directory&amp;utm_campaign=gpu_pricing">GPU pricing</a>), and bge-m3 at $0.01 per 1M tokens (<code>/v1/models</code>).</p>
<h2>GPU side: the job</h2>
<p>The job uploads a folder with the corpus and one script. The command creates a venv with uv, installs torch for CUDA 12.8 and sentence-transformers, and runs:</p>
<pre><code class="language-python">import json, time, torch
from sentence_transformers import SentenceTransformer

docs = [json.loads(l)["text"] for l in open("corpus.jsonl")]
model = SentenceTransformer("BAAI/bge-m3", device="cuda", model_kwargs={"torch_dtype": torch.float16})
model.max_seq_length = 512

def timed_encode(texts, bs):
    torch.cuda.synchronize(); t0 = time.time()
    v = model.encode(texts, batch_size=bs, normalize_embeddings=True, convert_to_numpy=True)
    torch.cuda.synchronize()
    return v, time.time() - t0

model.encode(docs[:256], batch_size=64, normalize_embeddings=True)   # warm-up
vecs, s = timed_encode(docs, 64)                                      # the real corpus, once
for bs in (16, 64, 128):                                              # steady state: corpus x20
    _, s20 = timed_encode(docs * 20, bs)
</code></pre>
<p>Submitted with the Python SDK, with a hard cap on runtime:</p>
<pre><code class="language-python">from runcrate import Runcrate

rc = Runcrate()
up = rc.jobs.uploads.upload("./code")
job = rc.jobs.run(command=open("command.sh").read(), gpu_type=["A4000", "A6000"],
                  max_runtime_minutes=20, template="ubuntu-train", code={"upload_id": up.upload_id})
</code></pre>
<h3>GPU results</h3>
<table>
<thead>
<tr>
<th>Step</th>
<th>Time</th>
</tr>
</thead>
<tbody><tr>
<td>Import torch + sentence-transformers</td>
<td>8.6 s</td>
</tr>
<tr>
<td>Load bge-m3 onto the GPU</td>
<td>17.5 s</td>
</tr>
<tr>
<td><strong>Embed the 527 chunks once</strong> (batch 64)</td>
<td><strong>3.95 s</strong> (133.5 chunks/s, 41.7k tokens/s)</td>
</tr>
<tr>
<td>Corpus x20, batch 16</td>
<td>65.1 s (161.9 chunks/s, <strong>50.6k tokens/s</strong>)</td>
</tr>
<tr>
<td>Corpus x20, batch 64</td>
<td>71.3 s (147.8 chunks/s)</td>
</tr>
<tr>
<td>Corpus x20, batch 128</td>
<td>75.4 s (139.8 chunks/s)</td>
</tr>
</tbody></table>
<p>Peak GPU memory was 2.6 GB, and the job's per-minute GPU metrics read 69 to 95% utilization while the x20 passes ran. Bigger batches were slower here, not faster.</p>
<p>The clock and the bill, from the job's event log and the billing transaction:</p>
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody><tr>
<td>Submitted to machine running</td>
<td>4 min 24 s (not billed)</td>
</tr>
<tr>
<td>Machine running to command start</td>
<td>67.8 s (billed)</td>
</tr>
<tr>
<td>Command</td>
<td>250 s (of which 211 s was our x20 benchmark)</td>
</tr>
<tr>
<td>Command end to machine stopped</td>
<td>12.1 s (billed)</td>
</tr>
<tr>
<td><strong>Billed</strong></td>
<td><strong>329.8 s = $0.0183</strong></td>
</tr>
</tbody></table>
<h2>API side</h2>
<pre><code class="language-python">from concurrent.futures import ThreadPoolExecutor
from openai import OpenAI

api = OpenAI(base_url="https://api.runcrate.ai/v1", api_key=os.environ["RUNCRATE_API_KEY"])
batches = [docs[i:i + 32] for i in range(0, len(docs), 32)]

with ThreadPoolExecutor(max_workers=4) as pool:
    out = list(pool.map(lambda b: api.embeddings.create(model="BAAI/bge-m3", input=b), batches))
</code></pre>
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody><tr>
<td>Wall time for all 527 chunks (17 requests, 4 in flight)</td>
<td><strong>5.46 s</strong></td>
</tr>
<tr>
<td>Billed tokens</td>
<td>166,694</td>
</tr>
<tr>
<td>Cost, from the request ledger (<code>list_requests</code>)</td>
<td><strong>$0.00175</strong> (an effective $0.0105 per 1M tokens)</td>
</tr>
</tbody></table>
<p>Are they the same embeddings? For the first 100 chunks, 99 of the API vectors have cosine similarity of at least 0.9998 with the GPU vectors. The one outlier (0.92) is a 3,533-character table chunk: our GPU script truncates at 512 tokens, the API embedded all of it. That is a setting, not a model difference.</p>
<h2>So which is cheaper?</h2>
<p>Per token, the GPU is far cheaper once it is running: at batch 16 the A4000 did 50.6k tokens/s for $0.20/hour, which is <strong>$0.0011 per 1M tokens</strong> against the API's $0.0105. About 9.5x.</p>
<p>But a job has a fixed cost before the first token: here 67.8 s of machine setup, 26.1 s to import and load the model, and 12.1 s to shut down, about 106 billed seconds, or <strong>$0.0059</strong>. That alone is about 3.4 times what the API charged for the whole corpus.</p>
<p>Putting the two together:</p>
<ul>
<li>GPU cost ≈ $0.0059 + tokens × $0.0011 / 1M</li>
<li>API cost ≈ tokens × $0.0105 / 1M</li>
<li>Break-even: about <strong>630k tokens</strong>, nearly four times the size of these docs.</li>
</ul>
<table>
<thead>
<tr>
<th>Corpus size</th>
<th>API</th>
<th>A4000 job (estimated from the measured rates)</th>
</tr>
</thead>
<tbody><tr>
<td>167k tokens (these docs)</td>
<td>$0.00175, 5 s</td>
<td>$0.0061, about 10 min including the wait for a machine</td>
</tr>
<tr>
<td>1M tokens</td>
<td>$0.0105</td>
<td>$0.0070</td>
</tr>
<tr>
<td>10M tokens</td>
<td>$0.105</td>
<td>$0.017</td>
</tr>
<tr>
<td>100M tokens</td>
<td>$1.05</td>
<td>$0.116 (about 33 min of embedding)</td>
</tr>
</tbody></table>
<p>The rows past the first are arithmetic from the measured throughput and overhead, not separate runs.</p>
<h2>What we'd do</h2>
<ul>
<li><strong>Under about a million tokens, or anything interactive: the API.</strong> The money is trivial either way, and 5 seconds beats a 10-minute round trip, most of which was waiting for the machine.</li>
<li><strong>Re-embedding a big corpus (tens of millions of tokens) on a schedule: a GPU job.</strong> The fixed cost disappears into the total and you pay about a tenth per token. Use batch 16 to 64 for this kind of text, not the biggest batch that fits.</li>
<li><strong>Measure your own overhead.</strong> The 106 seconds above include a template where the pip installs finished in about a second. If your job installs dependencies from scratch or downloads a large model every time, the break-even moves up; cache the model on a volume.</li>
</ul>
<p>Everything here is from one run on 2026-10-24. Prices move; check <code>/api/gpu-pricing</code> and <code>/v1/models</code> on the day you decide.</p>
<p>The job API we used is documented in <a href="https://www.runcrate.ai/docs/jobs/overview?utm_source=hashnode&amp;utm_medium=directory&amp;utm_campaign=gpu_pricing">Jobs</a>.</p>
<p><em>Written with AI assistance. Every number above was measured on 2026-10-24 and checked against the job log, the event timeline and the billing records.</em></p>
]]></content:encoded></item></channel></rss>