How to Stop a Long LLM Answer From Freezing When the Server Runs Out of Memory
> Engineer's view. This is memory overcommit. Back every page up front and half your RAM sits idle. Overcommit, and an out-of-memory killer stops a process at peak. An LLM server faces the same choice, because each request's memory grows with its answer. You pay in idle GPU memory or in frozen streams.
>
> TL;DR. An LLM server must decide how much memory to hold for an answer before the model has written it. Hold too much and you waste capacity. Hold too little and a running answer gets paused to make room.
Explain Like I'm 5
Picture a restaurant where nobody knows how long each family will stay. You could save a giant
table for every family, just in case. Most families leave early, so half your seats sit empty while
people wait outside.
Or you could seat everyone at small tables and hope. When the room gets full, you ask the newest
family to stand up and wait, even in the middle of dinner. They keep their food. They just can't
eat for a while.
An AI server runs that restaurant, and every answer is a family.
The Problem
You have shipped this bug already, in a system with no model in it.
You ran a service on Kubernetes. You set its memory request to the worst case you had ever seen.
Nothing crashed. But the cluster reported itself full while the real usage sat near 40%, and new
pods stayed pending.
So you lowered the request to the average. The cluster filled up nicely. Then a busy afternoon
came, several pods grew at once, and the kernel killed one in the middle of its work.
You were guessing a number before you knew the answer. An LLM server guesses that number for every
request.
The memory here is the KV cache: the model's saved work for every token so far. Without it, the
model would reread the whole conversation for each new token. It grows by one token per token
generated. The model only decides the length by stopping, so at the moment a request starts, nobody
knows its final size. The caller sends max_tokens, but that is a ceiling, not a forecast.
Under load, that guess shows up in two ways. Your GPU memory looks full while throughput sits far
below what you paid for. Or a streaming answer stops mid-sentence for many seconds, then carries on
as if nothing happened. Neither one logs an error.
The fix: find out which rule your server uses, measure how long your answers really are, then set
max_tokens and your concurrency limit from those lengths. New to the KV cache? Start at
How to Work Out How Much GPU Memory One Request Really Needs.
The Fix: Match the Limits to Your Server's Admission Rule
Admission is the moment a server lets a waiting request start. Three popular servers give three
different answers to one question: what must be free first?
!One unknown length, three admission rules, two possible bills
What does each server hold back?
TGI counts the prompt plus the full max_new_tokens against its budget. It cannot run out mid-answer.
SGLang counts max_new_tokens times a ratio, clipped at 4,096 tokens. The ratio starts at 0.7 and
falls to about 0.1 while things go well. When memory runs out, it resets from what requests really
used.
vLLM counts only what exists now. Its --watermark setting can keep some memory free, but the default
is zero.
What happens when memory runs out?
The server evicts a running request. It frees that request's cache and sends it to the front of
the waiting line. vLLM picks the newest request. SGLang picks the one with the least output.
The answer isn't lost. Tokens already generated are kept. The request must rebuild its cache by
rereading the prompt and its own output, and only then does the stream move again. In vLLM, nothing
new starts during a step that evicted.
Is the redone work the expensive part?
I expected it to be. It wasn't. My first simulation was built to show a server thrashing, and
throughput never collapsed. Rereading is much faster than generating.
!Long answers: tokens per second by admission rule (simulated, median of 9 runs)
Eviction redid 80% of the prefill work and still delivered about five times the throughput. The
price landed elsewhere: 26% of finished requests were evicted, and their median freeze was 58
seconds. Throughput hid that completely.
What This Means for You
When this matters. You run your own model server, or you are choosing one. It matters most when
answers vary widely in length, as they do with reasoning models and agents. If you only call a hosted
API, this is the provider's problem. You can still detect it from your side, and your max_tokens
may still matter.
How it affects you. The same GPU can give very different results depending on which rule it runs.
On a reserving server, one team's careless limit costs everyone. In my chat simulation, moving
max_tokens from 8,192 to 4,096 raised throughput 29%, from 351 to 454 tokens per second. Answers
were short, so about 0.5% would have been cut. On an evicting server, average throughput looks
healthy while a few users stare at a stopped stream. Your dashboard's mean hides them.
What to do about it. Start with no setup. Log the largest gap between chunks on your streaming
calls. A gap of many seconds in the middle of an answer is your first sign.
Next, log how many tokens your answers really use. Compare that with the max_tokens you send.
Most teams send a default nobody chose.
Then cap concurrency from those lengths. On an evicting server, that cap removed more freezing than
any other setting I tried. Finally, check the eviction counter and alert on it.
Implementing It
The change.
Four jobs, and one person may do all of them.
Job 1, whoever owns the client. Measure the freeze from the outside. This works against any server.
In your streaming call:
gap, last = 0.0, None
for chunk in client.chat.completions.create(model=MODEL, messages=msgs, stream=True):
now = time.monotonic()
gap, last = max(gap, now - last) if last else 0.0, now
metrics.observe("stream_max_gap_seconds", gap)
Start timing after the first chunk, as this does, so that queue time isn't counted as a freeze.
Job 2, whoever sets max_tokens. Choose it from logged lengths, not from the model's limit. Take a
value a little above your longest normal answer, then count what it would cut:
limit = sorted(lengths)[int(0.999 * len(lengths))]
cut = sum(n > limit for n in lengths) / len(lengths)
A wrong limit shows up as finish_reason: "length". Alert on that rate.
Job 3, whoever runs the server. Work out two limits from the same logs. safe_concurrency() in
code_example.py does this:
never = pool / (mean_prompt + p95_answer) # almost no evictions, low throughput
average = pool / (mean_prompt + mean_answer) # the memory is full on an average mix
pool is your KV cache size in tokens. Those limits came out at 4 and 15 for the long-answer case. The upper
limit is the useful one here. It gave up 17% of throughput, cut evicted requests from 26% to 7%, and
cut the median freeze from 58 to 17 seconds.
!Median freeze of an evicted request, by concurrency cap (seconds)
Job 4, the setting for your server. On vLLM, lower admission without shrinking the batch buffers:
vllm serve "$MODEL" --max-num-active-seqs 15
--max-num-seqs also works, and the docs recommend it. Be careful with --watermark: at 0.2 it cut
redone work from 80% to 53%, but the median freeze rose to 247 seconds, because an evicted request
then waits for extra room.
On SGLang, watch for the pool-full warning. Its tuning guide says to raise
--schedule-conservativeness to about 1.3 if the warning fires often. Lower it to about 0.3 if token
usage stays low while requests queue. On TGI, set --max-total-tokens as low as your longest real
request. TGI is in maintenance mode, so weigh that for a new deployment.
How you know it worked.
On vLLM, this should sit near zero:
rate(vllm:num_preemptions_total[5m])
vllm:kv_cache_usage_perc should stay high, or your cap is too low. On any server, p99 of
stream_max_gap_seconds should fall while total tokens per second holds. If usage drops and waiting
requests climb, you set the cap too low. Raise it until evictions just reappear.
When Tuning Admission Is the Wrong Tool
When you never fill the pool. If vllm:kv_cache_usage_perc stays well under 1 at peak, no rule is
making a difference. Fix something else.
When batch work doesn't care. A nightly job nobody watches can freeze for a minute at no cost. Leave
concurrency high, and take the throughput.
When you are simply out of capacity. A cap moves waiting from the stream to the queue. It doesn't
make more GPU. In my runs, the queue still grew, and your time to first token will show that.
When a limit would cut real work. A low max_tokens on a reasoning model can end the answer before
it starts. Check finish_reason first.
Treat my numbers as a shape, not a measurement. They come from a simulation that follows each
server's source code, with invented lengths and an assumed prefill speed. Single runs also varied widely: the same setup froze for 26 to 170 seconds across nine seeds. It
ignores prefix caching, which can make a reread much cheaper. I did not run these servers on a GPU. If prefill is slow, or
contexts are long, eviction costs more than it did here.
Three questions before you change anything:
- How much longer is your p95 answer than your mean answer?
- Does your server hold memory for max_tokens, or only for what exists?
- Would a user notice a ten-second pause, or only a slower batch?
Glossary
- KV cache — the model's saved work for every token so far, which grows as the answer grows.
- admission — the moment a server lets a waiting request start using memory.
- evicts — frees a running request's cache and sends it back to wait.
- prefill — processing input tokens to build the cache, much faster than generating.
- max_tokens — the most tokens a caller allows for one answer; a ceiling, not a forecast.
- watermark — a share of memory vLLM can keep free when admitting requests.
Further reading
- Optimization and Tuning — vLLM documentation
- scheduler.py in vLLM — vLLM repository
- Hyperparameter Tuning — SGLang documentation
- Launcher reference — Hugging Face TGI documentation
---
Originally published at theaicommit.com, a daily AI engineering lab for software engineers. The original has an interactive version you can play with, and the full runnable code is on GitHub.
Your LLM Serving Engine Ships Every 13 Days: Reading vLLM's Breaking-Change Lists as Upgrade Risk Data
TL;DR
- Cadence: In the 365 days to 2026-10-31, vLLM published 33 stable releases on PyPI, including 21 new minor series (0.12 through 0.32). The median gap between minor series was 13.5 days. SGLang published 19 releases, all inside 0.5.x (median gap 14.6 days). LMDeploy published 12 (median 26.5 days).
- Breaking-change volume: The vLLM release notes for 0.28 through 0.32 list 30 items under "Breaking changes". The per-release count rose every time: 4, 5, 6, 7, 8.
- The risky class: By my own classification, 8 of the 30 items (27%) change a default or a behavior while the exact same launch command still starts and passes a health check.
- Lag has a price too: Staying on 0.28 and jumping to 0.32 means reviewing 26 items at once, and 0.32.0 is the first of the five releases whose notes cite security advisories (three of them).
- What to do: Put an upgrade gate in front of every version bump: extract the breaking items automatically, diff the resolved server config, replay a golden request set. Working code for the first step is below.
- Scope: I did not run an inference engine for this post. Every number comes from the PyPI JSON API and the GitHub release notes, and I say where my judgment enters.
How often do LLM serving engines actually ship?
Most teams I read about treat a serving engine like a database: pick a version, pin it, upgrade "when needed". The release data says the engine moves much faster than that mental model.
Method: I pulled https://pypi.org/pypi/<package>/json, took the earliest upload time of any file for each version, and dropped release candidates, dev builds and post releases. The window is 2025-10-31 to 2026-10-31.
| Engine | Stable releases | Median gap (days) | Longest gap (days) | Latest in window |
|---|---|---|---|---|
| vLLM | 33 | 9.9 | 32.3 | 0.32.0 (2026-10-18) |
| SGLang | 19 | 14.6 | 41.7 | 0.5.23 (2026-10-30) |
| LMDeploy | 12 | 26.5 | 43.1 | 0.18.0 (2026-09-28) |
I left TensorRT-LLM out on purpose. Its PyPI listing shows 24 releases and a last upload of 1.2.1 on 2026-04-20, and I cannot tell whether PyPI is its primary channel. Guessing from a possibly incomplete source would put a number in this table that I cannot defend.
!Stable PyPI releases per quarter for vLLM, SGLang and LMDeploy
Of vLLM's 33 releases, 12 are patch releases. If you count only the first release of each minor series, you get 21 series with gaps of 15, 32, 9, 27, 9, 13, 13, 24, 17, 14, 14, 16, 11, 13, 16, 15, 13, 12, 13 and 13 days. The median is 13.5, the shortest is 9 and the longest is 32. The last four gaps (0.28 to 0.29 to 0.30 to 0.31 to 0.32) are 13, 12, 13 and 13 days, which is 52 days from 0.28 to 0.32.
Does the version number tell you how risky an upgrade is?
No, and the two big engines show why. vLLM is on 0.y.z, and the middle number moves with every scheduled release, so "0.31 to 0.32" is the normal unit of change. SGLang stayed in 0.5 for all 19 releases, so its normal unit is the patch number. A "patch bump" in one project is the same size of event as a "minor bump" in the other. Neither number carries a compatibility promise you can read off. The release notes are the only signal.
What did vLLM list as breaking changes from 0.28 to 0.32?
The GitHub notes for vLLM 0.28 through 0.32 each contain a bullet named "Breaking changes" with semicolon-separated entries. I counted entries. Two caveats: older releases label this differently (the 0.27.0 notes, for example, call the PyTorch upgrade "a breaking environment change" inside a highlight and have no dedicated bullet), so I used only the five releases with the dedicated bullet. And one entry can bundle several changes, such as "two environment variables removed", so the counts are a floor on what changed, not an exact tally.
!Breaking-change items per vLLM release, by class
| Release | Date | Items | Examples |
|---|---|---|---|
| 0.28.0 | 2026-08-26 | 4 | bitsandbytes moved to an out-of-tree plugin; calculate_kv_scales removed |
| 0.29.0 | 2026-09-09 | 5 | ten deprecated model architectures removed; PyAV video decoder backend removed |
| 0.30.0 | 2026-09-22 | 6 | scale-out endpoints now opt-in via --enable-scale-out; GPTQ g_idx removed |
| 0.31.0 | 2026-10-05 | 7 | tokenizer_mode="slow" removed; --enforce-eager now also disables JIT kernel warmup |
| 0.32.0 | 2026-10-18 | 8 | Python 3.10 dropped; default CORS origins restricted; max_tokens no longer reserved from the context window |
I then sorted all 30 entries into four classes. This is my judgment, and a few entries could go either way.
| Class | Count | Examples from the notes |
|---|---|---|
| Dependency or Python pin | 3 | Python 3.10 dropped (0.32); transformers>=5.16.1,<5.20.0 (0.32); Transformers bumped to 5.15.0 (0.28) |
| Default or behavior changed | 8 | default CORS origins restricted (0.32); max_tokens no longer reserved from the context window (0.32); --enforce-eager also disables JIT warmup (0.31); YaRN aligned with Transformers so vendor aliases no longer re-scale max_model_len (0.30) |
| Removed or renamed flag, env var, feature, backend | 15 | tokenizer_mode="slow" removed (0.31); --enable-mamba-fine-grained-prefix-cache renamed (0.31); calculate_kv_scales removed (0.28) |
| Deprecation or API rename | 4 | python -m vllm.entrypoints.openai.api_server deprecated for vllm serve (0.29); Responses API reasoning stream events renamed (0.32) |
The four classes add up to 3 + 8 + 15 + 4 = 30.
Why are the behavior-change items the dangerous ones?
A removed flag usually announces itself: the deploy fails at launch, or your staging smoke test does. A removed environment variable is murkier, because plenty of programs ignore variables they do not recognize. Whether yours errors out is something to test on your exact version, not something to assume.
The 8 behavior items are different in kind. The command is identical. The process starts. The health endpoint returns 200. What changed is what the server does with the traffic. Four of them, read as an operator:
- max_tokens no longer reserved from the context window (0.32). Capacity plans that assumed a long max_tokens reserves context for every request may now behave differently. The notes do not say whether admitted concurrency goes up or down for your workload. I would replay traffic with realistic max_tokens values and compare, rather than guess.
- --enforce-eager now also disables JIT kernel warmup (0.31). If you run eager mode to save memory or to debug, I would expect first-request behavior to change. I have not measured it. Cold-start p99 is the number to compare.
- YaRN aligned with Transformers (0.30). If you serve a long-context checkpoint that relies on YaRN scaling, the effective max_model_len can differ from before. Compare the context length the server reports at startup, old version against new.
- Default CORS origins restricted (0.32). This is a security improvement and the direction is right. It is still a behavior change that can break a browser client that talks to the server directly.
This is the same family of failure as the baked-in config trap I covered in an earlier post on KV cache dtype: identical flags, different conditions. The fix there was to compare the resolved configuration, not the command line.
What does falling behind cost?
!Items to review: one jump from 0.28 versus stepwise upgrades
Arithmetic from the same five release notes. Upgrading one release at a time, you review 5, 6, 7 and 8 items on the way from 0.28 to 0.32. Staying on 0.28 and jumping straight to 0.32, you review 5 + 6 + 7 + 8 = 26 items in a single change window, and you cannot bisect which one broke you because they all landed together.
The pinned option also has a cost on the security side. The 0.32.0 notes cite three GitHub security advisories as fixed: GHSA-4hhp-h66f-j5j7 (chat template resource exhaustion), GHSA-q97c-p368-75c4 (tokenizer max_token_id) and GHSA-frvg-qw88-hww9 (P2P ZMQ socket exhaustion). A text search for "GHSA" in the 0.28 to 0.31 bodies finds none. Maintainers can describe a fix without citing an ID, and the notes do not say whether anything is backported to older lines, so read this as "the three advisories I can see are in 0.32.0", not as proof that earlier versions were unsafe.
At a 13.5-day median gap, a "we upgrade quarterly" policy skips roughly 6 to 7 minor series per jump. Small, frequent, gated upgrades give you a smaller diff each time and let you pick up fixes sooner. That is an opinion, but it follows directly from the numbers above.
How do you build an upgrade gate?
Four layers, each aimed at a class from the table:
| Class | What catches it |
|---|---|
| Dependency or Python pin | Build the new image in CI, run pip check, boot it |
| Removed or renamed | Startup smoke test with the exact production arguments |
| Default or behavior changed | Resolved-config diff plus golden request replay |
| Deprecation or API rename | Grep startup logs for deprecation warnings, schedule the removal |
Step 1: extract the breaking items automatically
This script pulls the "Breaking changes" bullet for any release tags you give it. I ran it against v0.29.0 through v0.32.0 and it returned 5, 6, 7 and 8 items, 26 in total, which matches the counts above.
import json, re, sys, urllib.request
def breaking_items(tag, repo="vllm-project/vllm"):
url = f"https://api.github.com/repos/{repo}/releases/tags/{tag}"
body = json.load(urllib.request.urlopen(url, timeout=30))["body"] or ""
m = re.search(r"\* \*\*Breaking changes?\*\*:(.*?)\n", body)
return [s.strip() for s in m.group(1).split(";")] if m else []
if __name__ == "__main__":
total = 0
for tag in sys.argv[1:]:
items = breaking_items(tag)
total += len(items)
print(f"## {tag}: {len(items)} items")
for it in items:
print(" -", it)
print("total:", total)
Run it as python breaking.py v0.30.0 v0.31.0 v0.32.0. It depends on the notes keeping this bullet format, which held for 0.28 through 0.32 and did not hold for earlier releases, so treat an empty result as "go read the notes", not as "nothing broke".
Step 2: diff the resolved config, not the command line
Start the candidate version in staging with the exact production arguments. Save the startup log and the model-listing response from the old and the new server, then diff them. You are looking for any value you did not set that moved: context length, default dtypes, enabled endpoints, warmup behavior. This is what catches the YaRN and --enforce-eager style changes.
Step 3: replay a golden request set
The sketch below is illustrative. I did not run it here because it needs two live endpoints. It sends a fixed set of prompts at temperature 0 to the old and the new server and compares text, finish reason, token count and latency.
import json, statistics, time, urllib.request
def call(base, model, prompt, max_tokens=256):
body = json.dumps({"model": model, "temperature": 0, "max_tokens": max_tokens,
"messages": [{"role": "user", "content": prompt}]}).encode()
req = urllib.request.Request(base + "/v1/chat/completions", body,
{"Content-Type": "application/json"})
t0 = time.perf_counter()
r = json.load(urllib.request.urlopen(req, timeout=120))
c = r["choices"][0]
return (c["message"]["content"], c["finish_reason"],
r["usage"]["completion_tokens"], time.perf_counter() - t0)
def compare(old, new, model, prompts):
same, lat_old, lat_new, fin = 0, [], [], 0
for p in prompts:
a, b = call(old, model, p), call(new, model, p)
same += a[0] == b[0]
fin += a[1] != b[1]
lat_old.append(a[3]); lat_new.append(b[3])
n = len(prompts)
return {"identical_text": same / n, "finish_reason_changed": fin / n,
"p50_old": statistics.median(lat_old), "p50_new": statistics.median(lat_new)}
Temperature 0 does not guarantee identical text across engine versions, or even across batch compositions on the same version, because kernels and reduction orders change. So do not demand 100% identical text. Track the identical fraction as a trend, treat a jump in finish_reason_changed as a red flag, and score a task-level metric (exact match, pass rate) on the same prompts for the decision that matters.
Step 4: canary with a rollback condition
Send a small share of live traffic to the new version and write the rollback condition down before you start: error rate, p99 latency, and one quality proxy. If you cannot name the number that triggers rollback, you are not ready to canary.
What are the limits of this analysis?
- Release times are PyPI upload times, not GitHub tag times. The two can differ by hours.
- The breaking-change counts depend on how maintainers wrote one bullet. A longer bullet is not necessarily a bigger break.
- The four-way classification is mine. Reasonable people would move a few entries.
- I covered one engine's notes in depth. SGLang and LMDeploy appear only in the cadence table.
- Nothing here measures performance or accuracy. It measures how much changes, and how loudly.
FAQ
How often does vLLM release a new version?
Over the 365 days to 2026-10-31, PyPI shows 33 stable releases, including 21 new minor series. The median gap between minor series was 13.5 days, and the last four gaps were 13, 12, 13 and 13 days.
Does the version number tell me whether an upgrade is breaking?
Not in vLLM or SGLang. vLLM bumps the middle number every scheduled release, and SGLang stayed within 0.5.x for 19 releases. Read the release notes instead.
Which breaking changes are most dangerous for production?
The ones where your launch command still works but behavior differs: changed defaults, changed accounting such as how max_tokens is treated, changed context-length handling. In the five releases I classified, that was 8 of 30 items.
Should I pin an old version for stability?
Pinning is fine as a short-term choice, but it grows the eventual jump (26 items from 0.28 to 0.32) and delays fixes. The 0.32.0 notes list three security advisory fixes. A small upgrade every few weeks, behind a gate, is easier to debug than one big jump.
How do I find breaking changes without reading every note?
Parse the "Breaking changes" bullet with the script above, then read only the entries it returns. Empty output means the format changed, so read the full notes.
Does temperature 0 give identical output across engine versions?
No guarantee. Kernel and scheduling changes can alter floating-point reduction order. Compare identical-text rate as a trend and use a task metric for pass/fail.
Sources
- vllm on PyPI, sglang on PyPI, lmdeploy on PyPI
- vLLM release notes: v0.28.0, v0.29.0, v0.30.0, v0.31.0, v0.32.0
Further reading
- KV Cache Quantization in LLM Serving: FP8 and INT8 Tradeoffs, the Silent config.json Trap, and How to Measure
- Five Ways Your LLM Serving Benchmark Is Lying to You (and How to Catch Each One)
1 comment
vLLM now has PLE disk offload support on nightly
169 points · 21 comments
NyoomFloat12: How a 12-Bit Fixed-Length Format Doubles LLM Inference Throughput Without Losing a Single Bit
NyoomFloat12: How a 12-Bit Fixed-Length Format Doubles LLM Inference Throughput Without Losing a Single Bit
Serving large language models at scale is fundamentally a memory problem. During autoregressive decoding, every generated token requires reading the entire weight matrix and the accumulated KV cache from GPU high-bandwidth memory (HBM). On an H100, that bandwidth ceiling is around 3.35 TB/s — fast, but finite. As context windows grow and batch sizes increase, the gap between what the GPU can compute and what it can actually move through memory widens.
Lossless weight compression is one lever practitioners can pull to ease this bottleneck. The idea is simple: store weights in a smaller format, decompress on the fly during inference, and net a bandwidth gain. The catch is that decompression itself must be cheap enough not to eat the savings. A new paper from researchers at Stanford and Together AI introduces NyoomFloat12 (NF12), a 12-bit fixed-length format that threads this needle by exploiting a structural property of trained BF16 weights that turns out to be nearly universal.
The Redundancy Hidden in BF16 Weights
BFloat16 uses 1 sign bit, 8 exponent bits, and 7 mantissa bits. The exponent field is where the redundancy lives. In a trained model, the distribution of weight magnitudes is tightly concentrated — most weights are small, and after applying a per-matrix power-of-two scale to normalize the distribution, the upper four bits of the exponent field are 0111 for more than 99.7% of weights across every tested LLM.
Those four bits are constant. Storing them is waste. NF12 drops them.
The result: eight BF16 weights (128 bits) pack into 96 bits — 12 bytes instead of 16. That is a 25% reduction in weight storage, achieved without any approximation.
For the small fraction of weights whose exponent falls outside the expected range, NF12 uses an escape mechanism. A zero meta byte signals that a group of eight weights is stored verbatim in a per-matrix BF16 buffer. The overhead is negligible, and the format remains strictly lossless.
Why Fixed-Length Matters for GPUs
The prior art in lossless LLM weight compression — most notably DFloat11, presented at NeurIPS 2025 — uses Huffman coding on the exponent bits. Huffman achieves slightly higher compression (around 10.79 bits per weight versus NF12's 12.02), but it produces variable-length encodings.
Variable-length codes are a poor fit for SIMT (Single Instruction, Multiple Threads) GPU architectures. When threads in a warp decode weights of different lengths, they diverge — some finish early and stall while others catch up. The decompression kernel becomes serialized, and the bandwidth savings evaporate into compute overhead. DFloat11's decode kernel takes around 1,771 microseconds on the benchmarks reported in the NF12 paper. NF12's fixed-length decoder takes 548 microseconds — a 3.2× speedup in decompression alone.
NF12's decode path is designed around GPU warp execution. A warp reconstructs groups of weights in lockstep: 13 register operations plus a three-instruction escape check. The common path costs less than a standard BF16 memory copy. That is the key property — the decoder is not just fast, it is faster than the alternative of not compressing at all.
Two Regimes, Two Wins
The NF12 paper evaluates performance across two distinct inference regimes, and the format delivers in both.
Bandwidth-bound inference (small batch, short context): Here the bottleneck is how fast weights can be streamed from HBM to the compute units. NF12's fused GEMV (general matrix-vector multiplication) kernel achieves 1.26× the throughput of a custom BF16 GEMV and 1.35× over cuBLAS on an H100. The 25% smaller weight footprint translates almost directly into a proportional bandwidth gain, minus the small decode overhead.
Capacity-bound inference (large batch, long context): Here the bottleneck is total GPU memory. The KV cache competes with model weights for HBM space, and as context grows, the cache wins — crowding out concurrency. By shrinking the weight footprint, NF12 frees HBM for KV cache. On Qwen3-32B, NF12 provides 4.5× more KV cache capacity compared to BF16. At high concurrency, this translates to 2.54× higher rollout throughput — the headline number from the paper.
That 2.54× figure is not a cherry-picked benchmark. It reflects a realistic serving scenario where many sequences are processed simultaneously and the KV cache is the binding constraint.
Practical Implications for Inference Engineers
NF12 is a drop-in optimization for existing model weights. No retraining, no fine-tuning, no accuracy degradation — the format is lossless by construction. The compression step is a one-time offline operation. At inference time, the fused GEMV kernel handles decompression transparently.
The format is most impactful in two situations practitioners encounter regularly:
1. Fitting larger models on existing hardware. A 32B model in NF12 occupies roughly the same HBM as a 24B model in BF16. That headroom can be used for longer contexts, larger batches, or simply running a model that previously required an extra GPU.
2. Improving throughput at long context. As context windows push into the hundreds of thousands of tokens, KV cache pressure becomes the dominant constraint. NF12's capacity gains compound with other KV cache optimizations like grouped query attention and prefix caching.
The comparison with DFloat11 is instructive for practitioners choosing between lossless methods. DFloat11 compresses slightly more aggressively and is well-suited for scenarios where the goal is fitting a model into a fixed memory budget and throughput is secondary. NF12 is the better choice when end-to-end inference speed is the objective — its fixed-length decoder keeps the GPU's SIMT execution efficient.
What This Tells Us About the Inference Optimization Landscape
NF12 is part of a broader pattern in 2026 inference research: the recognition that the memory wall is the primary constraint, and that solutions must be co-designed with GPU architecture rather than borrowed from general-purpose compression. The Invariant Bit Packing (IBP) work from earlier this year takes a similar approach for PCIe transfers. The common thread is that compression formats designed for CPUs — Huffman, ANS, LZ4 — do not map cleanly onto SIMT execution models.
NF12's contribution is demonstrating that a carefully chosen fixed-length format can beat variable-length alternatives on end-to-end throughput even when it compresses less. The 25% size reduction is not the point; the SIMT-aligned decoder is.
For teams running inference at scale, NF12 is worth evaluating as a complement to quantization-based approaches. Unlike INT8 or FP8 quantization, it introduces zero approximation error — useful in domains where numerical fidelity matters, or when quantization has already been applied to the KV cache and further lossy compression of weights is undesirable.
The paper is available on alphaXiv. The authors are Liberman, Fang, Zhang, Dao, and Fu — several of whom have prior work on FlashAttention and efficient GPU kernels, which explains the focus on SIMT-aligned design.
For teams already using vLLM or SGLang for serving, NF12 is designed to integrate at the kernel level without changes to the serving framework's scheduling or batching logic. The authors plan to release the CUDA kernels as open source, which should make adoption straightforward once the code is available.
vLLM・SGLang・TensorRT-LLMをクラウドGPUで動かしたら、それぞれの個性が見えてきた
この記事で分かること
Qwen3-8Bを、L4 24GB 1台の上でvLLM・SGLang・TensorRT-LLMに載せ、同じリクエストを送った。揃えたつもりの条件が、5か所で揃っていなかった。どれもベンチマークの数字に入り込む。
速さの順位は載せない(速さは自分の条件で測ってほしい)。すべてこの版・L4(sm_89)での話である。エンジンを選びたいだけなら「エンジン別の要点」と最後の「私の使い分け」へ。
エンジン別の要点
エンジン
知っておくこと
vLLM 0.31.0
L4ではQwen/Qwen3-8B-FP8が重みだけFP8(W8A16)で動く
SGLang 0.5.21
ツール定義に項目が足され、入力が増える。L4・8B bf16は--mem-fraction-staticの自動値では起動しない(0.87で動いた)
TensorRT-LLM 1.2.1
L4ではQwen/Qwen3-8B-AWQ・-FP8をそのまま読めない。KVが足りないと黙って待たせる。キャッシュを消すAPIが無い
用語
用語
意味
KVキャッシュ
生成中のリクエストが、それまでのトークンの計算結果を置いておくGPUメモリ。容量をトークン数で数え、同時に処理できる本数の上限を決める
プリフィル
入力(プロンプト)をまとめて読み込む最初の段階。入力が長いほど重い
TTFT
Time To First Token。送ってから最初の1トークンが返るまでの時間
W8A16/W8A8
重み(Weight)と計算時の値(Activation)のビット数。W8A16は重みだけ8bitで計算は16bit
追い出し(preemption/retraction)
KVが足りなくなったとき、処理中のリクエストをいったん外すこと。後でやり直す
RadixAttention
SGLangのプレフィックスキャッシュ。木構造で、任意のトークン位置まで一致を使い回す(vLLMは決まった長さのブロック単位)
sm_89/Ada/Hopper
NVIDIA GPUの世代。L4はAda(sm_89)、H100はHopper(sm_90)
ModelOpt
NVIDIAの量子化ツール。TensorRT-LLM向けの量子化モデルを作る
条件
項目
値
モデル
Qwen3-8B(bf16、AWQ 4bit、FP8)
GPU
NVIDIA L4 24GB 1台(sm_89、AWS g6.xlarge)。ドライバ 595.112.07、CUDA 13.2
エンジン
vLLM 0.31.0 / SGLang 0.5.21 / TensorRT-LLM 1.2.1(trtllm-serve、PyTorchの経路)
リクエスト
/v1/chat/completions。ツール定義10個(ケースごとに40個から選ぶ)+日本語の質問。システムプロンプトなし、thinkingオフ、質問の末尾にリクエストごとの識別子
max_tokens/最大の文脈長
512/8,192
測った並行数
AWQ・FP8は1と16。bf16は1(16は載らない:3節)。SGLang bf16は容量の確かめで8も
送ったケース
200ケースのうち20ケースを繰り返し送った(並行数1で80件)
プレフィックスキャッシュ
3エンジンとも既定で有効。区切り(エンジン×チェックポイント×並行数×測定の種類)ごとにサーバーを起動し直す
エンジン別
vLLM
SGLang
TensorRT-LLM
入力長 最大/中央値(200ケース)
2,591/2,310
2,691/2,410
2,591/2,310
ツールパーサー
hermes
qwen(Qwen25Detector)
qwen3
バッチの上限
max_num_seqs 256・max_num_batched_tokens 2048
max_running_requests 2048
max_batch_size 2048・max_num_tokens 8192
チャンク化プリフィル
有効
有効(4096)
無効
CUDA graph
FULL_AND_PIECEWISE
有効
バッチ1〜32・64・128
バッチの上限はどれも並行数16より大きい。
1. 入力:SGLangだけトークン数に差が出た
同じリクエストでusage.prompt_tokensを比べると、SGLangだけ全ケースで+100トークンだった。
// 送ったもの({…}は省略)
{"type": "function", "function": {"name": "search_master", "description": "…", "parameters": {…}}}
// テンプレートに渡るもの
{"type": "function", "function": {"description": "…", "name": "search_master", "parameters": {…}, "strict": false}, "defer_loading": null}
エンジン
toolsの扱い
prompt_tokens
vLLM
型に通すが、テンプレートに渡す長さは変わらない
送ったまま手元でテンプレートに通した長さと一致
SGLang
model_dump()で項目を足し、キーの順が変わる
+100
TensorRT-LLM
—
vLLMと一致
書き換えを再現する関数を書き、1,200件でSGLang自身のmodel_dump()と突き合わせて違いは0件だった。確かめるなら、テンプレート適用後の文字列(vLLMなら/tokenize)の差分を取る。
切り替えるオプションは無い。 揃えるならクライアントから足した形で送る(他エンジンの入力も変わる)。今回はそのまま測った。
効き方:プリフィルが重くなる/KVの見積もりがずれる(3節)/精度にも混ざる。ツールが多いほど差は大きい。パーサーも3つとも別の実装で、読み損ねは精度の差として出る。
2. 量子化:「FP8」が同じ意味ではない
エンジン
起動時の警告
vLLM
Weight-only FP8 compression will be used leveraging the Marlin kernel.
SGLang
Using default W8A8 Block FP8 kernel config. Performance might be sub-optimal!
「FP8で比べた」表は、重みだけ8bitと計算も8bitが混ざりうる。起動ログのカーネル名で確かめる。
vLLMがL4でW8A16になるのは、カーネルの候補を順に試し、最初に使えるものを選ぶため(vllm/model_executor/kernels/linear/init.pyのinit_fp8_linear_kernel)。
CUTLASSのブロックFP8のカーネルはsm_90・sm_100・sm_120向けしか無い(csrc/.../c3x/scaled_mm_blockwise_sm*_fp8.cu)。--linear-backendで変えられるはずだが試していない。
TensorRT-LLM 1.2.1・L4では、公開の次の2つを読めなかった。
チェックポイント
読めない理由
代わり(ModelOpt 0.37.0)
Qwen/Qwen3-8B-AWQ(AutoAWQ形式)
load_hf_quant_configにawqの分岐が無い
--qformat int4_awq
Qwen/Qwen3-8B-FP8(ブロック単位)
対応表で、ブロック単位FP8はAda(sm_89)が対応外(HopperとBlackwell sm100/103は対応)。モデル側の表でもQwen3は対応外なので、H100でも使えない
--qformat fp8 --kv_cache_qformat none
2つで約56分かかった。nvidia/Qwen3-8B-FP8はKVまでFP8になるため使わなかった(KVの型の上書きは試していない)。TensorRT-LLMだけチェックポイントが別物なので、量子化した構成はエンジン間で比べず、各エンジン内でbf16との精度差だけ確かめた。
3. 容量:同じGPUでも載る量が違う
名前の似たメモリ設定が、何に対する割合かが違う(L4は約22.5GiB〔23,034MiB〕、Qwen3-8B bf16の重みは15.27GiB)。
| weights | KV | act | rest |
GPU |===============|==============|=========|======|
vLLM |<---------------- 0.92 ---------------->|
SGLang |<----------- 0.87 ----------->|
TRT-LLM |<-------- 0.9 x free --------->|
act=活性化など計算中の作業領域(プリフィルの中間結果、CUDA graph)。rest=エンジンの外で使われる分の余白。模式図で、長さは比率どおりではない。
エンジン
割合の対象
割合に含むもの
値
KVの容量(トークン)
容量を直接指定するなら
vLLM
GPU全体
重み+KV+活性化
0.92(既定)
30,240
--kv-cache-memory-bytes(指定すると0.92は無視される)
SGLang
GPU全体
重み+KV(活性化は外)
0.87(手で決めた)
26,813
--max-total-tokens
TensorRT-LLM
重みと活性化を確保した後の空き
KVだけ
0.9(既定)
約32,800
kv_cache_config.max_tokens(割合と両方なら小さいほう)
揃えたいなら割合ではなくトークン数で直接指定する(オプション名は版で変わりやすい)。今回は既定に近い状態を測るため揃えていない。
KV 1トークン = 36層 × KVヘッド8 × 128次元 × K/V 2 × 2バイト = 144KiB
1本の最大 = 入力2,691(SGLang) + max_tokens 512 + 余裕1 = 3,204
並行数8 = 3,204 × 8 = 25,632(+100前は 3,104 × 8 = 24,832)
並行数16 ≒ 51,300 → bf16は3エンジンとも載らない
全リクエストが最大長まで伸びても入るかで数え、入らない組み合わせは測っていない。
SGLangは自動値(0.684)で起動しなかった。 自動値=(GPUの容量−作業領域の見積もり)÷GPUの容量(srt/arg_groups/memory_hook.py)。
作業領域の見積もり(L4)
大きさ
固定の分
512MiB
プリフィル
chunked_prefill_size 4,096トークン×1.5MiB=6GiB(係数1.5はソースで「経験的な値」)
並列化の分
128MiB(1GPU)
CUDA graph
デコードの最大バッチ48×2MiB+プリフィルの分
合計
約7.1GiB(23,034MiB×(1−0.684))
ValueError: Loaded weights leave no GPU memory for the KV cache under --mem-fraction-static=0.684.
並行数8が載る値を0.01刻みで探した(0.01あたり約1,590トークン)。
割合
KVの容量
並行数8(25,632)
KV以外の残り
追い出し
0.684(自動)
起動しない
—
—
—
0.84
22,047
載らない
1.82 GB
—
0.86
25,224
載らない
1.40 GB
—
0.87
26,813
載る
1.11 GB
0回
+100前なら0.86で足りたが、1節の+100で408トークン足りなくなり0.87にした。0.88以上は試していない。
4. 溢れたとき:追い出すか、黙って待たせるか
方式
エンジン
混んだとき遅れる場所
見える指標
追い出す
vLLM・SGLang
生成の途中(ストリーミングが止まる)
vllm:num_preemptions/sglang:num_retracted_requests_total
待たせる
TensorRT-LLM
最初の1トークン(TTFT)
無い。numActiveRequestsで見る
TensorRT-LLMは「入力長+max_tokens」ぶんを先に確保するので、max_tokensを大きく付けるほど同時に受け入れる本数が減る。並行数16で送っても中では8本しか走っていないことがある。空きに合わせて詰め込むMAX_UTILIZATIONもある(処理中のリクエストが一時停止しうる。試していない)。
今回は/metricsを2秒ごとに読み、numActiveRequestsの最大が送った本数に届いたことを確かめた。溢れる組み合わせは測っていない(3節)。
5. 観測:そのまま読むと誤判定になる
エンジン
癖
対処
SGLang
キャッシュヒットが0だとprompt_tokens_detailsごと出ない
無ければ0
SGLang
num_retracted_requests_totalは初回の追い出しまで出ない
ゲージnum_retracted_reqsがあれば0回
TensorRT-LLM
kvCacheStatsはenable_iter_perf_statsが要り、1件処理するまで空
短いリクエストを1件送ってから読む
TensorRT-LLM
その1件で、測定の最初のリクエストがcached_tokens=1になった(<|im_start|>を共有。partial reuseが既定で有効なので、32トークンのブロックの一部でも当たる)
書式を通さない/v1/completionsで送る
TensorRT-LLM
反復ごとの統計は読むたびに消える
測定中に2秒ごとに読む
「読めなければ0」にしていたら誤った結論を出していた。「読めなければ止める」作りにしていたので気づけた。
エンジン
キャッシュの消し方
vLLM
POST /reset_prefix_cache(VLLM_SERVER_DEV_MODE=1)
SGLang
POST /flush_cache
TensorRT-LLM
APIなし。再起動
キャッシュがどれだけ効くかは、送るケースの数とKVの容量で大きく変わる。今回は20ケース(入力の合計45,957トークン)を繰り返し送った。
チェックポイント
KVの容量(トークン)
20ケースぶんが
cached_tokens/入力(3エンジンとも同程度)
bf16
26,813〜32,800
収まらず、一周する前に追い出される
3.6〜4.7%(当たるのは先頭の書式 約50トークンだけ)
AWQ・FP8
76,096〜99,552
収まり、2周目からツール定義ごと当たる
並行数1で約75%、16で約82%
効いているのは並行数ではなく、量子化で空いたKVの容量である。量子化した構成の速さには、計算が軽くなった分とキャッシュに当たった分が混ざりうる。ケースの数・繰り返し方とKVの容量を並べて確かめたい。
6. 動かすまで
版がぶつかるので、仮想環境をエンジンごとに分け、クライアントはHTTPだけで話す。
エンジン
torch
transformers
vLLM
2.13.0+cu132
5.17.0
SGLang
2.13.0
5.12.1
TensorRT-LLM
2.9.1+cu130
4.57.3
問題
対処
TensorRT-LLM環境でModelOptが動かない(NumPy<2固定と最新pyarrowが衝突)
pyarrow<22
TensorRT-LLM FP8が起動中にsegfault(原因不明、再実行で通過)
—
そのとき親プロセスが残り、20分待ち続けた
ログにSegfault encountered等が出たら即失敗にして1回だけ再起動
まとめ:比較を作る・読むときのチェックリスト
確かめること
確かめ方
モデルに届く入力は同じか
usage.prompt_tokensを並べる。テンプレート適用後の文字列を比べる
ツールパーサーは何か
起動オプションを書く
量子化の計算は何か
起動ログのカーネル名・警告
量子化のチェックポイントは同じか
対応表とソースで、使うGPUで読める形式か確かめる
KVの容量はいくつか
割合ではなくトークン数で見る。揃えるなら直接指定する
溢れたら何が起きるか
追い出しカウンタと同時実行数。max_tokensも書く
指標が読めないときの扱い
読めなければ止める
キャッシュの状態
有効か、区切りごとに消すか、cached_tokensはいくつか。ケースの数・繰り返し方とKVの容量も書く
私の使い分け(推奨案)
速さではなく、導入の手間・設定の素直さ・観測しやすさで決めた。
当てはまる範囲:24GBクラスのGPU1台・8B級・同時8本程度まで。大きいGPUや70B級ではメモリ(3節)と量子化の対応(2節)が変わる。
推奨
想定するユースケース
決め手
気をつけること
vLLM
社内チャットやRAGのPoC。公開モデル(量子化版を含む)を差し替えて評価する
公開チェックポイントをそのまま読める(2)。既定の設定で起動し、追い出しがカウンタで見える(3・4)
L4のFP8はW8A16(2)
SGLang
同じツール定義・システムプロンプトを先頭に付けるエージェントやバッチ処理
RadixAttention(用語)。L4でもFP8がW8A8(2)。※今回は共有部分が短く、実測ではない(5)
入力が増える(1)。割合を手で決める(3)。指標が0で出ない(5)。共有部分の長い順に処理するlpmは既定ではない(既定fcfs)
TensorRT-LLM
モデルを固定して長く運用する本番API
処理中のリクエストが途中でやり直しにならない(4)。量子化は最初の1回で済む
量子化を作り直す(2)。混むとTTFTが伸びるので、SLAがTTFTなら自分で測る(4)。キャッシュを消すAPIなし(5)
迷ったらvLLMで基準を作り、チェックリストで条件を揃えてから他と比べる。
付録:起動コマンド
vLLM 0.31.0(bf16。AWQ・FP8はモデル名だけ替える。VLLM_SERVER_DEV_MODE=1 を付けて起動)
vllm serve Qwen/Qwen3-8B --served-model-name Qwen/Qwen3-8B \
--host 127.0.0.1 --port 8000 \
--enable-auto-tool-choice --tool-call-parser hermes \
--enable-prompt-tokens-details --max-model-len 8192
SGLang 0.5.21
python -m sglang.launch_server --model-path Qwen/Qwen3-8B --served-model-name Qwen/Qwen3-8B \
--host 127.0.0.1 --port 30000 \
--tool-call-parser qwen --enable-metrics --enable-cache-report \
--mem-fraction-static 0.87 --context-length 8192
TensorRT-LLM 1.2.1(ModelOptで作ったAWQ。bf16はQwen/Qwen3-8B)
echo "enable_iter_perf_stats: true" > trtllm-serve.yaml
trtllm-serve ~/models/Qwen3-8B-int4-awq-modelopt \
--host 127.0.0.1 --port 8001 --backend pytorch \
--tool_parser qwen3 --config trtllm-serve.yaml --max_seq_len 8192
出典
SGLang v0.5.21:github.com/sgl-project/sglang/tree/v0.5.21 (srt/entrypoints/openai/serving_chat.py・protocol.py・usage_processor.py、srt/observability/metrics_collector.py、srt/function_call/function_call_parser.py、srt/arg_groups/memory_hook.py)
TensorRT-LLM v1.2.1:github.com/NVIDIA/TensorRT-LLM/tree/v1.2.1 (tensorrt_llm/commands/serve.py、llmapi/llm_args.py、_torch/model_config.py、_torch/modules/linear.py、cpp/tensorrt_llm/executor/jsonSerialization.cpp、docs/source/features/quantization.md)
ModelOpt 0.37.0:github.com/NVIDIA/Model-Optimizer/tree/0.37.0 (examples/llm_ptq/hf_ptq.py)
vLLM v0.31.0:github.com/vllm-project/vllm/tree/v0.31.0 (vllm/config/cache.py、vllm/engine/arg_utils.py、vllm/model_executor/layers/quantization/fp8.py、vllm/model_executor/kernels/linear/init.py・scaled_mm/marlin.py・cutlass.py・flashinfer.py・deep_gemm.py・b12x.py、vllm/utils/flashinfer.py、vllm/platforms/cuda.py、vllm/model_executor/layers/quantization/utils/w8a8_utils.py、csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/、vllm/entrypoints/openai/chat_completion/protocol.py、vllm/v1/metrics/loggers.py)、起動ログ
vLLM プレフィックスキャッシュの設計:docs.vllm.ai
TensorRT-LLM スケジューラの方針(GUARANTEED_NO_EVICT/MAX_UTILIZATION):nvidia.github.io
SGLang サーバー引数:docs.sglang.io 、new_token_ratioはsrt/managers/schedule_policy.py
nvidia/Qwen3-8B-FP8:huggingface.co
PagedAttention:Kwon et al., SOSP 2023。RadixAttention:Zheng et al., NeurIPS 2024
NCCL Roadmap Nov 2026 - Jan 2027 の解説
はじめに
!
NCCL 奥がふかすぎる。。。。にゃ。。。
NVIDIA が公開した NCCL Roadmap: Nov 2026 - Jan 2027 (GitHub Issue #2486) から、大規模分散学習・推論に携わるエンジニアが押さえるべきポイントを解説します。前回のロードマップの解説は「NCCL Roadmap Aug-Oct 2026 の解説」、そこで挙げた項目の現在までの実装状況は「NCCL 2.32.3 でロードマップはどう実装されたか」にあります。GIN の基本は前回の解説を前提にします。
この記事では、新しい概念の GENK と Flow、AWS EFA と GIN の現状、注目機能 2 つを扱います。調査は 2026 年 10 月 29 日に行い、まだリリースされていないコードは、staging ブランチを次のリリースの候補とみなして読んでいます。
実装状況の確かめ方
NVIDIA/nccl、aws-ofi-nccl、amazon-contributing/DeepEP、NVIDIA/nccl-extensions の公開ブランチを手元に取得し、commit の履歴とソースを読んで確かめました。PR と Issue の状態 (open か、マージされたか、閉じた日) は GitHub で確かめています。記事のソースへのリンクは、確かめたときの commit に固定しています。
NCCL は master、staging、dev の 3 本のブランチを公開していて、staging には 2.32.5-1 の版番号を付けた commit があり、その後に 2.32.6a0 に上がっています。ロードマップは「Coming Soon: NCCL 2.32 Update 1」の版番号を書いていませんが、この記事では staging のコードをその候補として読みます。
今回のロードマップの項目のうち、この記事で扱うものと、前回のロードマップの近い項目は次のとおりです。対応は筆者の読みです。
今回のロードマップの項目
前回のロードマップの近い項目
General Kernels (GENK)
Unify legacy kernels using Device API
Flow Device API
More Device API primitives
GIN Backend Improvements
GIN backend improvements
CFT を使う階層型カーネル
Compute Fabric Transport (CFT) through Device API
New Enqueue Pipeline
Legacy and symmetric-kernel host-side unification
ロードマップには、内部のメモリの削減、RAS の進み具合の監視、RMA の profiling など、この記事で扱わない項目もあります。
Device API とは何か
NCCL の API の全体像
ロードマップの項目を説明する前に、まず背景知識として NCCL の API を整理します。NCCL の API は、Host API、Device API、plugin API の 3 つに分けられます。Host API と Device API の仕様は NCCL の公式文書にあり、この記事の表はその章立てに沿っています。plugin API は公式文書の API の章には無く、リポジトリの plugins ディレクトリに plugin の種類ごとの説明があります。
API
何をするか
例
Host API
CPU から呼んで、communicator (通信に参加する GPU の集まり) の作成や集団通信を NCCL に頼みます。集団通信では、NCCL がアルゴリズムを選び、NCCL のカーネルを GPU で起動します。利用者の計算は別のカーネルです
ncclCommInitRank、ncclAllReduce
Device API
利用者が書いた CUDA カーネルの中から、ほかの GPU との通信を直接呼びます (2.28 から)。計算と通信を 1 つのカーネルに入れられます。使う前の準備には Host API を使います
GIN、LSA
plugin API
NCCL のほうから呼ぶ、下の部品の差し替え口です。ネットワークへの送受信や、アルゴリズムの選択の調整などを、NCCL の外の実装に任せます
net、GIN。説明は plugins にあります。aws-ofi-nccl は EFA 用の plugin
誰が NCCL を呼ぶか
NCCL を呼ぶのは、多くの場合アプリ自身ではなく、間に入るフレームワークやライブラリです。主な呼び手を、ソースで確かめた範囲で並べます。
呼び手
どう呼ぶか
使う API
PyTorch の torch.distributed (backend "nccl")
C++ の ProcessGroupNCCL が、ncclAllReduce などを直接呼びます
Host API
PyTorch の backend "nccl4py" (torch.distributed._nccl4py)
Python で書いた backend が、nccl4py を通して呼びます。init_process_group("nccl4py") で選びます
Host API
nccl4py
NVIDIA の公式の Python の bindings。Cython で libnccl を呼び、PyTorch の tensor や CuPy の配列をそのまま渡せます。実験的な CuTe DSL の部品もあります
主に Host API
vLLM の PyNccl
Python の ctypes で libnccl を読み込んで呼びます
Host API
PyTorch の symmetric memory の演算、DeepEP、NCCL EP
自分の CUDA カーネルの中から通信を呼びます。準備には Host API で window (次の節で説明します) を登録し、device communicator を作ります
Device API と Host API
aws-ofi-nccl など
NCCL のほうから呼ばれます。NCCL が EFA などのネットワークに送るときの下の部品です。InfiniBand には NCCL に組み込みの経路もあります
plugin API
Device API は何のためにあるか
Host API で通信を頼むと、通信は NCCL のカーネル 1 回分の単位になり、利用者の計算とは別のカーネルになります。計算の途中で決まった相手に少しずつ送りたい場合や、計算と通信を細かく重ねたい場合には、この単位が粗すぎます。たとえば MoE のトークンの振り分けでは、どのトークンをどの GPU に送るかが計算の途中で決まります。
Device API は、利用者のカーネルの中から、ほかの GPU のメモリへ直接データを動かせるようにするための API です。NCCL の Device API の例は、計算と通信を分けて順に行う従来のやり方に対して、カーネルの中で両方を細かく混ぜることで、待ち時間を減らし処理量を上げられると説明しています。
GPU のカーネルからほかの GPU に届く経路はいくつかあり、Device API は経路ごとに部品を用意しています。同じノードで NVLink や PCIe の P2P でつながった相手には、相手のメモリを普通の読み書きの命令で触れます (LSA)。NVLink のスイッチが複数の宛先への書き込みや足し合わせを引き受けられるシステムでは、Multimem を使います。ノードをまたぐ相手には、NIC を通して put (相手のメモリへの書き込み) や get (相手のメモリからの読み出し) を出します (GIN)。どちらも、相手が何も呼ばない片側通信です。CFT は、相手のメモリを番地 (ポインタ) で触る代わりに、CUDA が用意する宛先の番号 (logical endpoint) とオフセットを指定して put や get を出す部品で、Blackwell 以降の GPU で使えます。これらと組み合わせて使う、足し合わせやコピーの部品 (reduce と copy) も用意されています。
GIN の仕組みは、前回の解説の「GIN とは何か」で説明しました。LSA は後の GENK と Flow の節で、CFT は注目機能の節で扱います。Multimem と reduce と copy の部品は、この記事では詳しく扱いません。
どの部品を使う場合も、準備は利用者が CPU で Host API を呼んで行います。通信に使うバッファを、全ランクで同じ形に対応付けた領域 (symmetric memory window) として登録し、カーネルが使う資源を指定して device communicator を作り、それを渡して自分のカーネルを起動します。手順の例は NCCL の文書にあります。GIN を使うカーネルは、NCCL を更新したら作り直す必要があると文書は書いています。次の節からは、この Device API の上に加わる新しい部品とカーネルを見ていきます。
GENK と Flow とは何か
!
GENK と Flow は、まだリリースされておらず staging と dev にだけあるコードです。この節は staging の実装から読み取った内容で、リリースまでに変わる可能性があります。staging で試すには、ビルドのときに SYM_GENK=1 を指定し、実行のときに NCCL_SYM_GENK_ENABLE=1 を設定する必要があります。
概要
Flow は、前の節の LSA と GIN の上に作られた、集団通信の部品を提供する Device API です。ロードマップは Flow を「LSA と GIN の両方で動く、高水準の Device API」と説明しています。LSA は前の節で触れた、相手の GPU のメモリを普通の読み書きの命令で触る部品です。
GENK (General Kernels) は、NCCL が自分の Ring と Tree の集団通信を、この Flow の上で作り直したカーネルの一群です。Ring では AllGather、ReduceScatter、Broadcast、Reduce、AllReduce を、Tree では AllReduce を実装しています。
前回のロードマップには「Unify legacy kernels using Device API」という項目がありました。筆者は、GENK をこの項目につながる取り組みと読んでいます。今回のロードマップは、選んだプラットフォームで GENK を既定にし、古いカーネルを置き換えるとしています。あわせて性能モデルを 1 つにまとめ、どの実装を選ぶかを同じ物差しで決めるとしています。
staging では、NCCL 自身が同じ Flow で集団通信を組んでいます。Flow がリリースに入れば、利用者も NCCL の内部と同じ部品で通信カーネルを書けます。
NCCL の集団通信には、GENK の前から 2 種類のカーネルがあります。
legacy kernels: NCCL に最初からある Ring や Tree のカーネルです。経路の違いは、CPU の側が準備する transport (NVLink や PCIe の P2P、ネットワークなど) が受け持ち、ネットワークでは CPU の proxy スレッドが NIC に渡します。
symmetric カーネル: 全ランクが同じ形で登録したバッファ (symmetric memory window) を集団通信に渡したときに使える、新しいカーネルです。相手のバッファの番地が分かるので、Device API の LSA などで相手のメモリを直接読み書きします。いまあるものは、NVLink の直結を前提に、場面ごとに作り込まれています (表の「特化した symmetric カーネル」)。
GENK は、symmetric カーネルと同じ Device API の作りで、legacy kernels と同じくらい広い経路で動くものです。Ring で隣の GPU にデータを渡す場面で比べると、次のようになります。
隣の GPU との経路
legacy kernels
特化した symmetric カーネル
GENK
NVLink
動く
動く
動く (LSA)
PCIe の P2P
動く
使えない
動く (LSA)
別のノード (ネットワーク)
動く
一部の種類だけ動く (GIN)
動く (GIN)
表は、Hopper 以降の GPU で GENK を有効にした場合についてソースから読んだもので、動かして確かめたものではありません。PCIe の行は CUDA P2P が使える場合、GENK のネットワークの行は GIN Proxy (GPU の代わりに CPU のスレッドが NIC に送る GIN のバックエンド) が使える場合です。
特化した symmetric カーネルは、PCIe だけでつながったノードでは候補から外れます。GENK では、隣の GPU ごとに Flow が LSA か GIN かを実行時に選ぶので、同じカーネルのまま NVLink でも PCIe でもネットワークでも動きます。staging のカーネルの選択にも「特化したカーネルは NVLink の直結を要するが、GENK は CUDA P2P の LSA や GIN も使える」というコメントがあります。
利用者の側の例もあります。NCCL の Device API の例には、Transformer の RMSNorm の計算を ReduceScatter と AllGather の通信と 1 つのカーネルにまとめるものがあり、いまは LSA、Multimem、GIN を直接使って、受け取り、足し合わせ、送り出しを書いています。Flow は、NCCL 自身が集団通信に使うこの受け取り、足し合わせ、送り出しの部分を、部品として提供します。
NCCL の文書の Device API の章には、Flow と GENK の説明はまだありません。Flow の使い方は、ヘッダの説明にまとまっていて、Flow processor を足した commit が入口になります。GENK は、カーネルを足した commit と、NCCL の選択に組み込んだ commit が出発点です。
図で [API] のタグを付けた Flow processor、LSA connection、GIN connection は、アプリのカーネルからも呼べる Device API です。cost model、legacy kernels、specialized symmetric kernels、GENK は NCCL の中の部品です。
GIN の signal とは
GIN の put は片側通信で、受け手は何も呼ばないので、データがいつ届いたかを自分では知れません。signal は、それを知らせるための、受け手の側に置かれた数です。送り手が put に signal を付けると、データが届いたあとに受け手の数が増え (たとえば 1 増え)、受け手はその数が目標に達するまで待ってから読みます。GIN の基本は、前回の解説の「GIN とは何か」で説明しました。
signal には strong と weak の 2 種類があります。weak signal が「読んでよい」と約束するのは、その signal を付けた put のデータだけです。strong signal は、同じ相手に同じ context (GIN の通信の通り道の 1 本) で先に出した put のデータもすべて読めると約束します。そのため、最後の put にだけ signal を付ければ済みます。
EFA の送り方の SRD は、書き込みが届く順番を保ちません。そのため、最後の put に付けた signal が届いても、前の put のデータが届いたとは限りません。EFA の上の経路のうち、GPU から直接送る EFA GDA が提供するのは weak signal だけです (次の節で説明します)。一方、Flow の GIN の接続は strong signal を必要とします。
AWS EFA 関連アップデートと現在の課題
EFA の 2 つの GIN の経路と signal の種類
!
GENK と Flow の GIN の接続は strong signal を必要とします。EFA の上で strong signal を提供するのは GIN Proxy だけで、GPU から直接送る EFA GDA は weak signal しか提供しません。そのため、EFA の複数ノードで GENK を使うなら Proxy の経路になります。
EFA で GIN を使うときは、使う経路 (GIN のバックエンド) が、カーネルの必要とする種類の signal を提供するかを先に確かめます。NCCL は、カーネルが求める種類の signal を提供できないバックエンドを使わず、提供できる別のバックエンドがあればそちらに切り替え、無ければエラーにすると説明しています。EFA で使う GIN の経路として、ここでは 2 つを比べます。1 つは NCCL の GIN Proxy で、CPU のスレッドが aws-ofi-nccl の RMA の機能を通して EFA に送ります。もう 1 つは EFA GDA で、GPU のカーネルが EFA の送信キューに直接依頼を書きます。aws-ofi-nccl の文書によると、EFA GDA は NCCL_GIN_TYPE=5 で明示したときに使われます。同じ文書は、明示しなければ NCCL 2.31 は EFA で Proxy を使うと書いています。インスタンスやドライバなどの要件も同じ文書にあります。
2 つの経路は、提供する signal が違います。NCCL の Proxy は、strong signal と VA signal (登録した仮想アドレスに置く signal) を使えると報告します。EFA GDA については、aws-ofi-nccl が strong signal も VA signal も使えないと報告し、NCCL の側も番号で指す indexed signal だけを受け付けます。EFA GDA の signal は、EFA の NIC が届いた書き込みを数えるカウンタで作られています。aws-ofi-nccl の文書は、signal と counter の合計が EFA のデバイスあたり 512 個までであることも、制限に挙げています。
図の矢印は、使い手が必要とする signal をバックエンドが提供するかを示します。矢印はソースと文書から描いたもので、どの経路が選ばれるかや、動かした結果を示すものではありません。図の DeepEP の ordered は届く順番を前提にする上流のカーネル、unordered は順番を前提にしない amazon-contributing のフォークのカーネルです。
GENK はノードをまたぐ通信に使う GIN のバックエンドとして Proxy を指定しているので、staging のコードを読む限り、GENK が EFA の上で複数ノードの通信をするなら Proxy の経路を通ります。一方、aws-ofi-nccl の文書は、EFA GDA を使うときに symmetric の GIN カーネルを無効にするよう書いていて、この設定 (NCCL_SYM_GIN_KERNELS_ENABLE=0) にすると複数ノードの GENK は候補から外れます。つまり、EFA GDA を使う設定 (NCCL_GIN_TYPE=5 と NCCL_SYM_GIN_KERNELS_ENABLE=0) では複数ノードの GENK は使われず、複数ノードで GENK を試すときは、この 2 つの環境変数を設定せずに Proxy の経路で動かします。
aws-ofi-nccl v1.22.0 で、EFA GDA の Get がタグの付いた版で使えるようになった
Get は、GPU のカーネルが、ほかの GPU のメモリの中身を、持ち主に何もさせずに読み出す操作です。たとえば、手元の番号を見て、ほかの GPU が持つ表の行を取り出すような使い方ができます。Get の意義は「NCCL 2.32.3 でロードマップはどう実装されたか」で説明しました。
その記事の時点では、NCCL 2.32.3 には Get が入っていましたが、aws-ofi-nccl の側の対応はタグの無い master にしかありませんでした。2026 年 10 月 22 日 (日本時間) の v1.22.0 は、NCCL 2.32 が使うキューの形への対応と、Get の完了を数える修正を含む最初のタグです。これで、NCCL 2.32 と aws-ofi-nccl v1.22.0 という、タグの付いた版どうしに、EFA GDA の Get に要る変更がそろいました。v1.22.0 のリリースノートは Get に触れておらず、筆者は動かして確かめていません。
ただし、EFA GDA の Get には、完了を知らせる signal や counter を付けられません。読み出しが終わったかは、出した依頼をまとめて待つ Flush で確かめます。
MoE の通信ライブラリ (DeepEP と NCCL EP) の動き
MoE のトークンを担当の expert に配り (dispatch)、計算結果を集める (combine) 通信のライブラリにも動きがありました。DeepEP と NCCL EP はこの同じ役割の候補で、どちらも下で NCCL を使います。DeepEP は 2026 年 9 月 29 日の V2.5 で NVSHMEM への依存を取り除き、ノードをまたぐ通信は NCCL の GIN だけになりました。要件は NCCL 2.32.3 以降です。EFA で DeepEP を動かす取り組みは amazon-contributing/DeepEP のフォークにあり、その詳細は別の記事で整理してみます。
NVIDIA の NCCL EP は NVIDIA/nccl-extensions に移り、NCCL の staging からは、nccl_ep と nccl_m2n のディレクトリが消されました。
注目機能 2 選
CFT を使う階層型カーネル
CFT (Compute Fabric Transport) は NCCL 2.31 で Device API に加わった部品で、嬉しさは 2 つあります。1 つ目は、カーネルから直接届く相手を、LSA の範囲より広げられる見込みがあることです。LSA で届く相手は、CUDA P2P で相手のメモリを普通の読み書きの命令で触れる相手 (LSA team) に限られます。CFT は相手を番地ではなく CUDA が配る宛先の番号で指すので、届く範囲は、CUDA が「この番号で届く」と報告する範囲で決まります。前回のロードマップも、CFT を、logical endpoint で届く範囲の相手を宛先の番号で指す通信の形と説明していました。ただし、リリースされた版では、CFT で届く相手の集まり (CFT team) の大きさは既定で LSA team と同じです。
2 つ目は、受け手に届いたことを早く知らせる書き込みがあることです。CFT の普通の put や reduce で受け手に知らせるときは、データを書いたあと、書き込みの完了を確定させ (fence)、合図のフラグを書きます。これが counted な書き込みで、フラグの代わりに、NCCL_WIN_CFT_COUNTED を付けて登録した window の中のカウンタを使います。カウンタはデータより後に進むことが保証されているので、送り手は fence とフラグの書き込みを省けます。staging の文書は、これを低遅延のための操作と説明しています。受け手は、カウンタを見てからデータを読む間に fence を入れる必要があります。
仕組みは次のとおりです。CFT の宛先の番号は、CUDA 13.3 で加わった logical endpoint という仕組みです。NCCL は CPU の側で、宛先の番号を確保し、そこに各 GPU のバッファを結び付けておきます。カーネルは、送りたいデータを shared memory に置き、宛先の番号とオフセットを指定して put を出し、そのあと完了を確かめます。
dev では、CUDA 13.4 以降で CUDA が「logical endpoint で届く範囲」を報告すると、CFT team をその範囲から作ります。その範囲は LSA の範囲の倍数でなければならず、LSA より広い範囲を 1 つの CFT team にできる作りです。NCCL の文書は、CFT がどの物理的な経路を通るかを書いていません。NCCL EP の CFT を使う経路は、1 つの NVLink のドメインの中だけを対象にしています。
ロードマップは、この CFT を使って、AllGather、ReduceScatter、AlltoAll の階層型カーネルを作るとしています。階層型は、近い GPU の間と遠い GPU の間で通信を分ける作りで、LSA の範囲と、それより広い CFT team の範囲を使い分けることになると筆者は読んでいます。master、staging、dev を調べた範囲では、CFT の宛先の集まりの階層の種類は 2.32.3 のコードに定義されていますが、それを使う集団通信のカーネルは見当たりませんでした。
CFT とは
CFT が使えるのは、ハードウェア、ドライバ、CUDA が logical endpoint と fabric 用の命令に対応したシステムだけで、例の barrier のプログラムは CUDA 13.3 と SM_100 を要件に挙げています。CFT team を CUDA の報告する範囲 (clique) から作る変更は、dev の 2026 年 9 月 28 日の commit で入り、master と staging にはまだありません。
NCCL EP には、CFT の counted な書き込みで低遅延の dispatch を送る経路が、NCCL_EP_LL_CFT=1 で選んだときだけ使われる形で、2026 年 9 月 16 日の commit から main に入っています。
New Enqueue Pipeline: 呼び出しから起動までを 3 つの層に分ける
enqueue は、アプリが呼んだ集団通信や put を、カーネルやコピーの起動に変える NCCL の中の流れです。ロードマップは、これを管理 (Management)、準備 (Preparation)、スケジューリング (Scheduling) の 3 層に分け、制御の流れを簡単にし、カーネルの最適化をしやすくするとしています。
管理と準備の層を含む基本の枠組みは 2.31.2 から入っていて、その時点のスケジューリングの層は既存の経路に戻すだけでした。いまの staging と dev でも、新しい流れは既定で無効です。dev では、Preparation の tuning ですべての集団通信のアルゴリズムやプロトコルを先に選び、classification でその結果だけを使って起動の経路を振り分ける形にしました。経路ごとのスケジューラは、それぞれの仕事を stream ごとに 1 つのキューにまとめます。dev の commit では、新しい流れの symmetric のスケジューラが起動するのは特化した symmetric カーネルだけで、GENK は自分用の device communicator の準備が要るので、従来の流れの symmetric カーネルのスケジューラから起動されます。
まとめ
staging の GENK は、Hopper 以降の GPU でビルドと環境変数で有効にすると NVLink、PCIe、ネットワークのどの経路でも同じカーネルが候補になり、EFA の複数ノードでは strong signal を提供する GIN Proxy の経路を使います。CFT を使う階層型カーネルは部品だけが公開コードにあり、New Enqueue Pipeline は枠組みが既定で無効のまま dev で進んでいます。
⚡ Serve LLMs faster and cheaper with vLLM's PagedAttention, continuous batching and a drop-in OpenAI-compatible API. 🔗 vllm-project/vllm
Credits: vllm-project/vllm
#vllm #python #llm
3 likes
一応注意喚起, 少なくともvLLM v0.30でFA4を使うと本来出る速度の0.4倍ぐらいの速度しかでなくなってしまうので, version up推奨です
vllm#59362
9 likes · 1 repost · 613 views
FlashAttentionの速度が明らかにおかしくてこれ多分バグだろvLLMにコントリビュートチャンスと思ったら2026/09/08に既にPRは投げられており, 2週間前にclose, それを昨日気づいて車輪の再発明をしたという形だった。vLLM contributeチャンスを逃し悔しい
vllm#55866
9 likes · 945 views
Numbers from the open vLLM RFC: vllm#63626
Mentions
How to Stop a Long LLM Answer From Freezing When the Server Runs Out of Memory > Engineer's view. This is memory overcommit. Back every page up front and half your RAM sits idle. Overcommit, and an out-of-memory killer stops a process at peak. An LLM server faces the same choice, because each request's memory grows with its answer. You pay in idle GPU memory or in frozen streams. > > TL;DR. An LLM server must decide how much memory to hold for an answer before the model has written it. Hold too much and you waste capacity. Hold too little and a running answer gets paused to make room. Explain Like I'm 5 Picture a restaurant where nobody knows how long each family will stay. You could save a giant table for every family, just in case. Most families leave early, so half your seats sit empty while people wait outside. Or you could seat everyone at small tables and hope. When the room gets full, you ask the newest family to stand up and wait, even in the middle of dinner. They keep their food. They just can't eat for a while. An AI server runs that restaurant, and every answer is a family. The Problem You have shipped this bug already, in a system with no model in it. You ran a service on Kubernetes. You set its memory request to the worst case you had ever seen. Nothing crashed. But the cluster reported itself full while the real usage sat near 40%, and new pods stayed pending. So you lowered the request to the average. The cluster filled up nicely. Then a busy afternoon came, several pods grew at once, and the kernel killed one in the middle of its work. You were guessing a number before you knew the answer. An LLM server guesses that number for every request. The memory here is the KV cache: the model's saved work for every token so far. Without it, the model would reread the whole conversation for each new token. It grows by one token per token generated. The model only decides the length by stopping, so at the moment a request starts, nobody knows its final size. The caller sends max_tokens, but that is a ceiling, not a forecast. Under load, that guess shows up in two ways. Your GPU memory looks full while throughput sits far below what you paid for. Or a streaming answer stops mid-sentence for many seconds, then carries on as if nothing happened. Neither one logs an error. The fix: find out which rule your server uses, measure how long your answers really are, then set max_tokens and your concurrency limit from those lengths. New to the KV cache? Start at How to Work Out How Much GPU Memory One Request Really Needs. The Fix: Match the Limits to Your Server's Admission Rule Admission is the moment a server lets a waiting request start. Three popular servers give three different answers to one question: what must be free first? !One unknown length, three admission rules, two possible bills What does each server hold back? TGI counts the prompt plus the full max_new_tokens against its budget. It cannot run out mid-answer. SGLang counts max_new_tokens times a ratio, clipped at 4,096 tokens. The ratio starts at 0.7 and falls to about 0.1 while things go well. When memory runs out, it resets from what requests really used. vLLM counts only what exists now. Its --watermark setting can keep some memory free, but the default is zero. What happens when memory runs out? The server evicts a running request. It frees that request's cache and sends it to the front of the waiting line. vLLM picks the newest request. SGLang picks the one with the least output. The answer isn't lost. Tokens already generated are kept. The request must rebuild its cache by rereading the prompt and its own output, and only then does the stream move again. In vLLM, nothing new starts during a step that evicted. Is the redone work the expensive part? I expected it to be. It wasn't. My first simulation was built to show a server thrashing, and throughput never collapsed. Rereading is much faster than generating. !Long answers: tokens per second by admission rule (simulated, median of 9 runs) Eviction redid 80% of the prefill work and still delivered about five times the throughput. The price landed elsewhere: 26% of finished requests were evicted, and their median freeze was 58 seconds. Throughput hid that completely. What This Means for You When this matters. You run your own model server, or you are choosing one. It matters most when answers vary widely in length, as they do with reasoning models and agents. If you only call a hosted API, this is the provider's problem. You can still detect it from your side, and your max_tokens may still matter. How it affects you. The same GPU can give very different results depending on which rule it runs. On a reserving server, one team's careless limit costs everyone. In my chat simulation, moving max_tokens from 8,192 to 4,096 raised throughput 29%, from 351 to 454 tokens per second. Answers were short, so about 0.5% would have been cut. On an evicting server, average throughput looks healthy while a few users stare at a stopped stream. Your dashboard's mean hides them. What to do about it. Start with no setup. Log the largest gap between chunks on your streaming calls. A gap of many seconds in the middle of an answer is your first sign. Next, log how many tokens your answers really use. Compare that with the max_tokens you send. Most teams send a default nobody chose. Then cap concurrency from those lengths. On an evicting server, that cap removed more freezing than any other setting I tried. Finally, check the eviction counter and alert on it. Implementing It The change. Four jobs, and one person may do all of them. Job 1, whoever owns the client. Measure the freeze from the outside. This works against any server. In your streaming call: gap, last = 0.0, None for chunk in client.chat.completions.create(model=MODEL, messages=msgs, stream=True): now = time.monotonic() gap, last = max(gap, now - last) if last else 0.0, now metrics.observe("stream_max_gap_seconds", gap) Start timing after the first chunk, as this does, so that queue time isn't counted as a freeze. Job 2, whoever sets max_tokens. Choose it from logged lengths, not from the model's limit. Take a value a little above your longest normal answer, then count what it would cut: limit = sorted(lengths)[int(0.999 * len(lengths))] cut = sum(n > limit for n in lengths) / len(lengths) A wrong limit shows up as finish_reason: "length". Alert on that rate. Job 3, whoever runs the server. Work out two limits from the same logs. safe_concurrency() in code_example.py does this: never = pool / (mean_prompt + p95_answer) # almost no evictions, low throughput average = pool / (mean_prompt + mean_answer) # the memory is full on an average mix pool is your KV cache size in tokens. Those limits came out at 4 and 15 for the long-answer case. The upper limit is the useful one here. It gave up 17% of throughput, cut evicted requests from 26% to 7%, and cut the median freeze from 58 to 17 seconds. !Median freeze of an evicted request, by concurrency cap (seconds) Job 4, the setting for your server. On vLLM, lower admission without shrinking the batch buffers: vllm serve "$MODEL" --max-num-active-seqs 15 --max-num-seqs also works, and the docs recommend it. Be careful with --watermark: at 0.2 it cut redone work from 80% to 53%, but the median freeze rose to 247 seconds, because an evicted request then waits for extra room. On SGLang, watch for the pool-full warning. Its tuning guide says to raise --schedule-conservativeness to about 1.3 if the warning fires often. Lower it to about 0.3 if token usage stays low while requests queue. On TGI, set --max-total-tokens as low as your longest real request. TGI is in maintenance mode, so weigh that for a new deployment. How you know it worked. On vLLM, this should sit near zero: rate(vllm:num_preemptions_total[5m]) vllm:kv_cache_usage_perc should stay high, or your cap is too low. On any server, p99 of stream_max_gap_seconds should fall while total tokens per second holds. If usage drops and waiting requests climb, you set the cap too low. Raise it until evictions just reappear. When Tuning Admission Is the Wrong Tool When you never fill the pool. If vllm:kv_cache_usage_perc stays well under 1 at peak, no rule is making a difference. Fix something else. When batch work doesn't care. A nightly job nobody watches can freeze for a minute at no cost. Leave concurrency high, and take the throughput. When you are simply out of capacity. A cap moves waiting from the stream to the queue. It doesn't make more GPU. In my runs, the queue still grew, and your time to first token will show that. When a limit would cut real work. A low max_tokens on a reasoning model can end the answer before it starts. Check finish_reason first. Treat my numbers as a shape, not a measurement. They come from a simulation that follows each server's source code, with invented lengths and an assumed prefill speed. Single runs also varied widely: the same setup froze for 26 to 170 seconds across nine seeds. It ignores prefix caching, which can make a reread much cheaper. I did not run these servers on a GPU. If prefill is slow, or contexts are long, eviction costs more than it did here. Three questions before you change anything: - How much longer is your p95 answer than your mean answer? - Does your server hold memory for max_tokens, or only for what exists? - Would a user notice a ten-second pause, or only a slower batch? Glossary - KV cache — the model's saved work for every token so far, which grows as the answer grows. - admission — the moment a server lets a waiting request start using memory. - evicts — frees a running request's cache and sends it back to wait. - prefill — processing input tokens to build the cache, much faster than generating. - max_tokens — the most tokens a caller allows for one answer; a ceiling, not a forecast. - watermark — a share of memory vLLM can keep free when admitting requests. Further reading - Optimization and Tuning — vLLM documentation - scheduler.py in vLLM — vLLM repository - Hyperparameter Tuning — SGLang documentation - Launcher reference — Hugging Face TGI documentation --- Originally published at theaicommit.com, a daily AI engineering lab for software engineers. The original has an interactive version you can play with, and the full runnable code is on GitHub.
Your LLM Serving Engine Ships Every 13 Days: Reading vLLM's Breaking-Change Lists as Upgrade Risk Data TL;DR - Cadence: In the 365 days to 2026-10-31, vLLM published 33 stable releases on PyPI, including 21 new minor series (0.12 through 0.32). The median gap between minor series was 13.5 days. SGLang published 19 releases, all inside 0.5.x (median gap 14.6 days). LMDeploy published 12 (median 26.5 days). - Breaking-change volume: The vLLM release notes for 0.28 through 0.32 list 30 items under "Breaking changes". The per-release count rose every time: 4, 5, 6, 7, 8. - The risky class: By my own classification, 8 of the 30 items (27%) change a default or a behavior while the exact same launch command still starts and passes a health check. - Lag has a price too: Staying on 0.28 and jumping to 0.32 means reviewing 26 items at once, and 0.32.0 is the first of the five releases whose notes cite security advisories (three of them). - What to do: Put an upgrade gate in front of every version bump: extract the breaking items automatically, diff the resolved server config, replay a golden request set. Working code for the first step is below. - Scope: I did not run an inference engine for this post. Every number comes from the PyPI JSON API and the GitHub release notes, and I say where my judgment enters. How often do LLM serving engines actually ship? Most teams I read about treat a serving engine like a database: pick a version, pin it, upgrade "when needed". The release data says the engine moves much faster than that mental model. Method: I pulled https://pypi.org/pypi/<package>/json, took the earliest upload time of any file for each version, and dropped release candidates, dev builds and post releases. The window is 2025-10-31 to 2026-10-31. | Engine | Stable releases | Median gap (days) | Longest gap (days) | Latest in window | |---|---|---|---|---| | vLLM | 33 | 9.9 | 32.3 | 0.32.0 (2026-10-18) | | SGLang | 19 | 14.6 | 41.7 | 0.5.23 (2026-10-30) | | LMDeploy | 12 | 26.5 | 43.1 | 0.18.0 (2026-09-28) | I left TensorRT-LLM out on purpose. Its PyPI listing shows 24 releases and a last upload of 1.2.1 on 2026-04-20, and I cannot tell whether PyPI is its primary channel. Guessing from a possibly incomplete source would put a number in this table that I cannot defend. !Stable PyPI releases per quarter for vLLM, SGLang and LMDeploy Of vLLM's 33 releases, 12 are patch releases. If you count only the first release of each minor series, you get 21 series with gaps of 15, 32, 9, 27, 9, 13, 13, 24, 17, 14, 14, 16, 11, 13, 16, 15, 13, 12, 13 and 13 days. The median is 13.5, the shortest is 9 and the longest is 32. The last four gaps (0.28 to 0.29 to 0.30 to 0.31 to 0.32) are 13, 12, 13 and 13 days, which is 52 days from 0.28 to 0.32. Does the version number tell you how risky an upgrade is? No, and the two big engines show why. vLLM is on 0.y.z, and the middle number moves with every scheduled release, so "0.31 to 0.32" is the normal unit of change. SGLang stayed in 0.5 for all 19 releases, so its normal unit is the patch number. A "patch bump" in one project is the same size of event as a "minor bump" in the other. Neither number carries a compatibility promise you can read off. The release notes are the only signal. What did vLLM list as breaking changes from 0.28 to 0.32? The GitHub notes for vLLM 0.28 through 0.32 each contain a bullet named "Breaking changes" with semicolon-separated entries. I counted entries. Two caveats: older releases label this differently (the 0.27.0 notes, for example, call the PyTorch upgrade "a breaking environment change" inside a highlight and have no dedicated bullet), so I used only the five releases with the dedicated bullet. And one entry can bundle several changes, such as "two environment variables removed", so the counts are a floor on what changed, not an exact tally. !Breaking-change items per vLLM release, by class | Release | Date | Items | Examples | |---|---|---|---| | 0.28.0 | 2026-08-26 | 4 | bitsandbytes moved to an out-of-tree plugin; calculate_kv_scales removed | | 0.29.0 | 2026-09-09 | 5 | ten deprecated model architectures removed; PyAV video decoder backend removed | | 0.30.0 | 2026-09-22 | 6 | scale-out endpoints now opt-in via --enable-scale-out; GPTQ g_idx removed | | 0.31.0 | 2026-10-05 | 7 | tokenizer_mode="slow" removed; --enforce-eager now also disables JIT kernel warmup | | 0.32.0 | 2026-10-18 | 8 | Python 3.10 dropped; default CORS origins restricted; max_tokens no longer reserved from the context window | I then sorted all 30 entries into four classes. This is my judgment, and a few entries could go either way. | Class | Count | Examples from the notes | |---|---|---| | Dependency or Python pin | 3 | Python 3.10 dropped (0.32); transformers>=5.16.1,<5.20.0 (0.32); Transformers bumped to 5.15.0 (0.28) | | Default or behavior changed | 8 | default CORS origins restricted (0.32); max_tokens no longer reserved from the context window (0.32); --enforce-eager also disables JIT warmup (0.31); YaRN aligned with Transformers so vendor aliases no longer re-scale max_model_len (0.30) | | Removed or renamed flag, env var, feature, backend | 15 | tokenizer_mode="slow" removed (0.31); --enable-mamba-fine-grained-prefix-cache renamed (0.31); calculate_kv_scales removed (0.28) | | Deprecation or API rename | 4 | python -m vllm.entrypoints.openai.api_server deprecated for vllm serve (0.29); Responses API reasoning stream events renamed (0.32) | The four classes add up to 3 + 8 + 15 + 4 = 30. Why are the behavior-change items the dangerous ones? A removed flag usually announces itself: the deploy fails at launch, or your staging smoke test does. A removed environment variable is murkier, because plenty of programs ignore variables they do not recognize. Whether yours errors out is something to test on your exact version, not something to assume. The 8 behavior items are different in kind. The command is identical. The process starts. The health endpoint returns 200. What changed is what the server does with the traffic. Four of them, read as an operator: - max_tokens no longer reserved from the context window (0.32). Capacity plans that assumed a long max_tokens reserves context for every request may now behave differently. The notes do not say whether admitted concurrency goes up or down for your workload. I would replay traffic with realistic max_tokens values and compare, rather than guess. - --enforce-eager now also disables JIT kernel warmup (0.31). If you run eager mode to save memory or to debug, I would expect first-request behavior to change. I have not measured it. Cold-start p99 is the number to compare. - YaRN aligned with Transformers (0.30). If you serve a long-context checkpoint that relies on YaRN scaling, the effective max_model_len can differ from before. Compare the context length the server reports at startup, old version against new. - Default CORS origins restricted (0.32). This is a security improvement and the direction is right. It is still a behavior change that can break a browser client that talks to the server directly. This is the same family of failure as the baked-in config trap I covered in an earlier post on KV cache dtype: identical flags, different conditions. The fix there was to compare the resolved configuration, not the command line. What does falling behind cost? !Items to review: one jump from 0.28 versus stepwise upgrades Arithmetic from the same five release notes. Upgrading one release at a time, you review 5, 6, 7 and 8 items on the way from 0.28 to 0.32. Staying on 0.28 and jumping straight to 0.32, you review 5 + 6 + 7 + 8 = 26 items in a single change window, and you cannot bisect which one broke you because they all landed together. The pinned option also has a cost on the security side. The 0.32.0 notes cite three GitHub security advisories as fixed: GHSA-4hhp-h66f-j5j7 (chat template resource exhaustion), GHSA-q97c-p368-75c4 (tokenizer max_token_id) and GHSA-frvg-qw88-hww9 (P2P ZMQ socket exhaustion). A text search for "GHSA" in the 0.28 to 0.31 bodies finds none. Maintainers can describe a fix without citing an ID, and the notes do not say whether anything is backported to older lines, so read this as "the three advisories I can see are in 0.32.0", not as proof that earlier versions were unsafe. At a 13.5-day median gap, a "we upgrade quarterly" policy skips roughly 6 to 7 minor series per jump. Small, frequent, gated upgrades give you a smaller diff each time and let you pick up fixes sooner. That is an opinion, but it follows directly from the numbers above. How do you build an upgrade gate? Four layers, each aimed at a class from the table: | Class | What catches it | |---|---| | Dependency or Python pin | Build the new image in CI, run pip check, boot it | | Removed or renamed | Startup smoke test with the exact production arguments | | Default or behavior changed | Resolved-config diff plus golden request replay | | Deprecation or API rename | Grep startup logs for deprecation warnings, schedule the removal | Step 1: extract the breaking items automatically This script pulls the "Breaking changes" bullet for any release tags you give it. I ran it against v0.29.0 through v0.32.0 and it returned 5, 6, 7 and 8 items, 26 in total, which matches the counts above. import json, re, sys, urllib.request def breaking_items(tag, repo="vllm-project/vllm"): url = f"https://api.github.com/repos/{repo}/releases/tags/{tag}" body = json.load(urllib.request.urlopen(url, timeout=30))["body"] or "" m = re.search(r"\* \*\*Breaking changes?\*\*:(.*?)\n", body) return [s.strip() for s in m.group(1).split(";")] if m else [] if __name__ == "__main__": total = 0 for tag in sys.argv[1:]: items = breaking_items(tag) total += len(items) print(f"## {tag}: {len(items)} items") for it in items: print(" -", it) print("total:", total) Run it as python breaking.py v0.30.0 v0.31.0 v0.32.0. It depends on the notes keeping this bullet format, which held for 0.28 through 0.32 and did not hold for earlier releases, so treat an empty result as "go read the notes", not as "nothing broke". Step 2: diff the resolved config, not the command line Start the candidate version in staging with the exact production arguments. Save the startup log and the model-listing response from the old and the new server, then diff them. You are looking for any value you did not set that moved: context length, default dtypes, enabled endpoints, warmup behavior. This is what catches the YaRN and --enforce-eager style changes. Step 3: replay a golden request set The sketch below is illustrative. I did not run it here because it needs two live endpoints. It sends a fixed set of prompts at temperature 0 to the old and the new server and compares text, finish reason, token count and latency. import json, statistics, time, urllib.request def call(base, model, prompt, max_tokens=256): body = json.dumps({"model": model, "temperature": 0, "max_tokens": max_tokens, "messages": [{"role": "user", "content": prompt}]}).encode() req = urllib.request.Request(base + "/v1/chat/completions", body, {"Content-Type": "application/json"}) t0 = time.perf_counter() r = json.load(urllib.request.urlopen(req, timeout=120)) c = r["choices"][0] return (c["message"]["content"], c["finish_reason"], r["usage"]["completion_tokens"], time.perf_counter() - t0) def compare(old, new, model, prompts): same, lat_old, lat_new, fin = 0, [], [], 0 for p in prompts: a, b = call(old, model, p), call(new, model, p) same += a[0] == b[0] fin += a[1] != b[1] lat_old.append(a[3]); lat_new.append(b[3]) n = len(prompts) return {"identical_text": same / n, "finish_reason_changed": fin / n, "p50_old": statistics.median(lat_old), "p50_new": statistics.median(lat_new)} Temperature 0 does not guarantee identical text across engine versions, or even across batch compositions on the same version, because kernels and reduction orders change. So do not demand 100% identical text. Track the identical fraction as a trend, treat a jump in finish_reason_changed as a red flag, and score a task-level metric (exact match, pass rate) on the same prompts for the decision that matters. Step 4: canary with a rollback condition Send a small share of live traffic to the new version and write the rollback condition down before you start: error rate, p99 latency, and one quality proxy. If you cannot name the number that triggers rollback, you are not ready to canary. What are the limits of this analysis? - Release times are PyPI upload times, not GitHub tag times. The two can differ by hours. - The breaking-change counts depend on how maintainers wrote one bullet. A longer bullet is not necessarily a bigger break. - The four-way classification is mine. Reasonable people would move a few entries. - I covered one engine's notes in depth. SGLang and LMDeploy appear only in the cadence table. - Nothing here measures performance or accuracy. It measures how much changes, and how loudly. FAQ How often does vLLM release a new version? Over the 365 days to 2026-10-31, PyPI shows 33 stable releases, including 21 new minor series. The median gap between minor series was 13.5 days, and the last four gaps were 13, 12, 13 and 13 days. Does the version number tell me whether an upgrade is breaking? Not in vLLM or SGLang. vLLM bumps the middle number every scheduled release, and SGLang stayed within 0.5.x for 19 releases. Read the release notes instead. Which breaking changes are most dangerous for production? The ones where your launch command still works but behavior differs: changed defaults, changed accounting such as how max_tokens is treated, changed context-length handling. In the five releases I classified, that was 8 of 30 items. Should I pin an old version for stability? Pinning is fine as a short-term choice, but it grows the eventual jump (26 items from 0.28 to 0.32) and delays fixes. The 0.32.0 notes list three security advisory fixes. A small upgrade every few weeks, behind a gate, is easier to debug than one big jump. How do I find breaking changes without reading every note? Parse the "Breaking changes" bullet with the script above, then read only the entries it returns. Empty output means the format changed, so read the full notes. Does temperature 0 give identical output across engine versions? No guarantee. Kernel and scheduling changes can alter floating-point reduction order. Compare identical-text rate as a trend and use a task metric for pass/fail. Sources - vllm on PyPI, sglang on PyPI, lmdeploy on PyPI - vLLM release notes: v0.28.0, v0.29.0, v0.30.0, v0.31.0, v0.32.0 Further reading - KV Cache Quantization in LLM Serving: FP8 and INT8 Tradeoffs, the Silent config.json Trap, and How to Measure - Five Ways Your LLM Serving Benchmark Is Lying to You (and How to Catch Each One)
vLLM now has PLE disk offload support on nightly
NyoomFloat12: How a 12-Bit Fixed-Length Format Doubles LLM Inference Throughput Without Losing a Single Bit NyoomFloat12: How a 12-Bit Fixed-Length Format Doubles LLM Inference Throughput Without Losing a Single Bit Serving large language models at scale is fundamentally a memory problem. During autoregressive decoding, every generated token requires reading the entire weight matrix and the accumulated KV cache from GPU high-bandwidth memory (HBM). On an H100, that bandwidth ceiling is around 3.35 TB/s — fast, but finite. As context windows grow and batch sizes increase, the gap between what the GPU can compute and what it can actually move through memory widens. Lossless weight compression is one lever practitioners can pull to ease this bottleneck. The idea is simple: store weights in a smaller format, decompress on the fly during inference, and net a bandwidth gain. The catch is that decompression itself must be cheap enough not to eat the savings. A new paper from researchers at Stanford and Together AI introduces NyoomFloat12 (NF12), a 12-bit fixed-length format that threads this needle by exploiting a structural property of trained BF16 weights that turns out to be nearly universal. The Redundancy Hidden in BF16 Weights BFloat16 uses 1 sign bit, 8 exponent bits, and 7 mantissa bits. The exponent field is where the redundancy lives. In a trained model, the distribution of weight magnitudes is tightly concentrated — most weights are small, and after applying a per-matrix power-of-two scale to normalize the distribution, the upper four bits of the exponent field are 0111 for more than 99.7% of weights across every tested LLM. Those four bits are constant. Storing them is waste. NF12 drops them. The result: eight BF16 weights (128 bits) pack into 96 bits — 12 bytes instead of 16. That is a 25% reduction in weight storage, achieved without any approximation. For the small fraction of weights whose exponent falls outside the expected range, NF12 uses an escape mechanism. A zero meta byte signals that a group of eight weights is stored verbatim in a per-matrix BF16 buffer. The overhead is negligible, and the format remains strictly lossless. Why Fixed-Length Matters for GPUs The prior art in lossless LLM weight compression — most notably DFloat11, presented at NeurIPS 2025 — uses Huffman coding on the exponent bits. Huffman achieves slightly higher compression (around 10.79 bits per weight versus NF12's 12.02), but it produces variable-length encodings. Variable-length codes are a poor fit for SIMT (Single Instruction, Multiple Threads) GPU architectures. When threads in a warp decode weights of different lengths, they diverge — some finish early and stall while others catch up. The decompression kernel becomes serialized, and the bandwidth savings evaporate into compute overhead. DFloat11's decode kernel takes around 1,771 microseconds on the benchmarks reported in the NF12 paper. NF12's fixed-length decoder takes 548 microseconds — a 3.2× speedup in decompression alone. NF12's decode path is designed around GPU warp execution. A warp reconstructs groups of weights in lockstep: 13 register operations plus a three-instruction escape check. The common path costs less than a standard BF16 memory copy. That is the key property — the decoder is not just fast, it is faster than the alternative of not compressing at all. Two Regimes, Two Wins The NF12 paper evaluates performance across two distinct inference regimes, and the format delivers in both. Bandwidth-bound inference (small batch, short context): Here the bottleneck is how fast weights can be streamed from HBM to the compute units. NF12's fused GEMV (general matrix-vector multiplication) kernel achieves 1.26× the throughput of a custom BF16 GEMV and 1.35× over cuBLAS on an H100. The 25% smaller weight footprint translates almost directly into a proportional bandwidth gain, minus the small decode overhead. Capacity-bound inference (large batch, long context): Here the bottleneck is total GPU memory. The KV cache competes with model weights for HBM space, and as context grows, the cache wins — crowding out concurrency. By shrinking the weight footprint, NF12 frees HBM for KV cache. On Qwen3-32B, NF12 provides 4.5× more KV cache capacity compared to BF16. At high concurrency, this translates to 2.54× higher rollout throughput — the headline number from the paper. That 2.54× figure is not a cherry-picked benchmark. It reflects a realistic serving scenario where many sequences are processed simultaneously and the KV cache is the binding constraint. Practical Implications for Inference Engineers NF12 is a drop-in optimization for existing model weights. No retraining, no fine-tuning, no accuracy degradation — the format is lossless by construction. The compression step is a one-time offline operation. At inference time, the fused GEMV kernel handles decompression transparently. The format is most impactful in two situations practitioners encounter regularly: 1. Fitting larger models on existing hardware. A 32B model in NF12 occupies roughly the same HBM as a 24B model in BF16. That headroom can be used for longer contexts, larger batches, or simply running a model that previously required an extra GPU. 2. Improving throughput at long context. As context windows push into the hundreds of thousands of tokens, KV cache pressure becomes the dominant constraint. NF12's capacity gains compound with other KV cache optimizations like grouped query attention and prefix caching. The comparison with DFloat11 is instructive for practitioners choosing between lossless methods. DFloat11 compresses slightly more aggressively and is well-suited for scenarios where the goal is fitting a model into a fixed memory budget and throughput is secondary. NF12 is the better choice when end-to-end inference speed is the objective — its fixed-length decoder keeps the GPU's SIMT execution efficient. What This Tells Us About the Inference Optimization Landscape NF12 is part of a broader pattern in 2026 inference research: the recognition that the memory wall is the primary constraint, and that solutions must be co-designed with GPU architecture rather than borrowed from general-purpose compression. The Invariant Bit Packing (IBP) work from earlier this year takes a similar approach for PCIe transfers. The common thread is that compression formats designed for CPUs — Huffman, ANS, LZ4 — do not map cleanly onto SIMT execution models. NF12's contribution is demonstrating that a carefully chosen fixed-length format can beat variable-length alternatives on end-to-end throughput even when it compresses less. The 25% size reduction is not the point; the SIMT-aligned decoder is. For teams running inference at scale, NF12 is worth evaluating as a complement to quantization-based approaches. Unlike INT8 or FP8 quantization, it introduces zero approximation error — useful in domains where numerical fidelity matters, or when quantization has already been applied to the KV cache and further lossy compression of weights is undesirable. The paper is available on alphaXiv. The authors are Liberman, Fang, Zhang, Dao, and Fu — several of whom have prior work on FlashAttention and efficient GPU kernels, which explains the focus on SIMT-aligned design. For teams already using vLLM or SGLang for serving, NF12 is designed to integrate at the kernel level without changes to the serving framework's scheduling or batching logic. The authors plan to release the CUDA kernels as open source, which should make adoption straightforward once the code is available.
vLLM・SGLang・TensorRT-LLMをクラウドGPUで動かしたら、それぞれの個性が見えてきた この記事で分かること Qwen3-8Bを、L4 24GB 1台の上でvLLM・SGLang・TensorRT-LLMに載せ、同じリクエストを送った。揃えたつもりの条件が、5か所で揃っていなかった。どれもベンチマークの数字に入り込む。 速さの順位は載せない(速さは自分の条件で測ってほしい)。すべてこの版・L4(sm_89)での話である。エンジンを選びたいだけなら「エンジン別の要点」と最後の「私の使い分け」へ。 エンジン別の要点 エンジン 知っておくこと vLLM 0.31.0 L4ではQwen/Qwen3-8B-FP8が重みだけFP8(W8A16)で動く SGLang 0.5.21 ツール定義に項目が足され、入力が増える。L4・8B bf16は--mem-fraction-staticの自動値では起動しない(0.87で動いた) TensorRT-LLM 1.2.1 L4ではQwen/Qwen3-8B-AWQ・-FP8をそのまま読めない。KVが足りないと黙って待たせる。キャッシュを消すAPIが無い 用語 用語 意味 KVキャッシュ 生成中のリクエストが、それまでのトークンの計算結果を置いておくGPUメモリ。容量をトークン数で数え、同時に処理できる本数の上限を決める プリフィル 入力(プロンプト)をまとめて読み込む最初の段階。入力が長いほど重い TTFT Time To First Token。送ってから最初の1トークンが返るまでの時間 W8A16/W8A8 重み(Weight)と計算時の値(Activation)のビット数。W8A16は重みだけ8bitで計算は16bit 追い出し(preemption/retraction) KVが足りなくなったとき、処理中のリクエストをいったん外すこと。後でやり直す RadixAttention SGLangのプレフィックスキャッシュ。木構造で、任意のトークン位置まで一致を使い回す(vLLMは決まった長さのブロック単位) sm_89/Ada/Hopper NVIDIA GPUの世代。L4はAda(sm_89)、H100はHopper(sm_90) ModelOpt NVIDIAの量子化ツール。TensorRT-LLM向けの量子化モデルを作る 条件 項目 値 モデル Qwen3-8B(bf16、AWQ 4bit、FP8) GPU NVIDIA L4 24GB 1台(sm_89、AWS g6.xlarge)。ドライバ 595.112.07、CUDA 13.2 エンジン vLLM 0.31.0 / SGLang 0.5.21 / TensorRT-LLM 1.2.1(trtllm-serve、PyTorchの経路) リクエスト /v1/chat/completions。ツール定義10個(ケースごとに40個から選ぶ)+日本語の質問。システムプロンプトなし、thinkingオフ、質問の末尾にリクエストごとの識別子 max_tokens/最大の文脈長 512/8,192 測った並行数 AWQ・FP8は1と16。bf16は1(16は載らない:3節)。SGLang bf16は容量の確かめで8も 送ったケース 200ケースのうち20ケースを繰り返し送った(並行数1で80件) プレフィックスキャッシュ 3エンジンとも既定で有効。区切り(エンジン×チェックポイント×並行数×測定の種類)ごとにサーバーを起動し直す エンジン別 vLLM SGLang TensorRT-LLM 入力長 最大/中央値(200ケース) 2,591/2,310 2,691/2,410 2,591/2,310 ツールパーサー hermes qwen(Qwen25Detector) qwen3 バッチの上限 max_num_seqs 256・max_num_batched_tokens 2048 max_running_requests 2048 max_batch_size 2048・max_num_tokens 8192 チャンク化プリフィル 有効 有効(4096) 無効 CUDA graph FULL_AND_PIECEWISE 有効 バッチ1〜32・64・128 バッチの上限はどれも並行数16より大きい。 1. 入力:SGLangだけトークン数に差が出た 同じリクエストでusage.prompt_tokensを比べると、SGLangだけ全ケースで+100トークンだった。 // 送ったもの({…}は省略) {"type": "function", "function": {"name": "search_master", "description": "…", "parameters": {…}}} // テンプレートに渡るもの {"type": "function", "function": {"description": "…", "name": "search_master", "parameters": {…}, "strict": false}, "defer_loading": null} エンジン toolsの扱い prompt_tokens vLLM 型に通すが、テンプレートに渡す長さは変わらない 送ったまま手元でテンプレートに通した長さと一致 SGLang model_dump()で項目を足し、キーの順が変わる +100 TensorRT-LLM — vLLMと一致 書き換えを再現する関数を書き、1,200件でSGLang自身のmodel_dump()と突き合わせて違いは0件だった。確かめるなら、テンプレート適用後の文字列(vLLMなら/tokenize)の差分を取る。 切り替えるオプションは無い。 揃えるならクライアントから足した形で送る(他エンジンの入力も変わる)。今回はそのまま測った。 効き方:プリフィルが重くなる/KVの見積もりがずれる(3節)/精度にも混ざる。ツールが多いほど差は大きい。パーサーも3つとも別の実装で、読み損ねは精度の差として出る。 2. 量子化:「FP8」が同じ意味ではない エンジン 起動時の警告 vLLM Weight-only FP8 compression will be used leveraging the Marlin kernel. SGLang Using default W8A8 Block FP8 kernel config. Performance might be sub-optimal! 「FP8で比べた」表は、重みだけ8bitと計算も8bitが混ざりうる。起動ログのカーネル名で確かめる。 vLLMがL4でW8A16になるのは、カーネルの候補を順に試し、最初に使えるものを選ぶため(vllm/model_executor/kernels/linear/init.pyのinit_fp8_linear_kernel)。 CUTLASSのブロックFP8のカーネルはsm_90・sm_100・sm_120向けしか無い(csrc/.../c3x/scaled_mm_blockwise_sm*_fp8.cu)。--linear-backendで変えられるはずだが試していない。 TensorRT-LLM 1.2.1・L4では、公開の次の2つを読めなかった。 チェックポイント 読めない理由 代わり(ModelOpt 0.37.0) Qwen/Qwen3-8B-AWQ(AutoAWQ形式) load_hf_quant_configにawqの分岐が無い --qformat int4_awq Qwen/Qwen3-8B-FP8(ブロック単位) 対応表で、ブロック単位FP8はAda(sm_89)が対応外(HopperとBlackwell sm100/103は対応)。モデル側の表でもQwen3は対応外なので、H100でも使えない --qformat fp8 --kv_cache_qformat none 2つで約56分かかった。nvidia/Qwen3-8B-FP8はKVまでFP8になるため使わなかった(KVの型の上書きは試していない)。TensorRT-LLMだけチェックポイントが別物なので、量子化した構成はエンジン間で比べず、各エンジン内でbf16との精度差だけ確かめた。 3. 容量:同じGPUでも載る量が違う 名前の似たメモリ設定が、何に対する割合かが違う(L4は約22.5GiB〔23,034MiB〕、Qwen3-8B bf16の重みは15.27GiB)。 | weights | KV | act | rest | GPU |===============|==============|=========|======| vLLM |<---------------- 0.92 ---------------->| SGLang |<----------- 0.87 ----------->| TRT-LLM |<-------- 0.9 x free --------->| act=活性化など計算中の作業領域(プリフィルの中間結果、CUDA graph)。rest=エンジンの外で使われる分の余白。模式図で、長さは比率どおりではない。 エンジン 割合の対象 割合に含むもの 値 KVの容量(トークン) 容量を直接指定するなら vLLM GPU全体 重み+KV+活性化 0.92(既定) 30,240 --kv-cache-memory-bytes(指定すると0.92は無視される) SGLang GPU全体 重み+KV(活性化は外) 0.87(手で決めた) 26,813 --max-total-tokens TensorRT-LLM 重みと活性化を確保した後の空き KVだけ 0.9(既定) 約32,800 kv_cache_config.max_tokens(割合と両方なら小さいほう) 揃えたいなら割合ではなくトークン数で直接指定する(オプション名は版で変わりやすい)。今回は既定に近い状態を測るため揃えていない。 KV 1トークン = 36層 × KVヘッド8 × 128次元 × K/V 2 × 2バイト = 144KiB 1本の最大 = 入力2,691(SGLang) + max_tokens 512 + 余裕1 = 3,204 並行数8 = 3,204 × 8 = 25,632(+100前は 3,104 × 8 = 24,832) 並行数16 ≒ 51,300 → bf16は3エンジンとも載らない 全リクエストが最大長まで伸びても入るかで数え、入らない組み合わせは測っていない。 SGLangは自動値(0.684)で起動しなかった。 自動値=(GPUの容量−作業領域の見積もり)÷GPUの容量(srt/arg_groups/memory_hook.py)。 作業領域の見積もり(L4) 大きさ 固定の分 512MiB プリフィル chunked_prefill_size 4,096トークン×1.5MiB=6GiB(係数1.5はソースで「経験的な値」) 並列化の分 128MiB(1GPU) CUDA graph デコードの最大バッチ48×2MiB+プリフィルの分 合計 約7.1GiB(23,034MiB×(1−0.684)) ValueError: Loaded weights leave no GPU memory for the KV cache under --mem-fraction-static=0.684. 並行数8が載る値を0.01刻みで探した(0.01あたり約1,590トークン)。 割合 KVの容量 並行数8(25,632) KV以外の残り 追い出し 0.684(自動) 起動しない — — — 0.84 22,047 載らない 1.82 GB — 0.86 25,224 載らない 1.40 GB — 0.87 26,813 載る 1.11 GB 0回 +100前なら0.86で足りたが、1節の+100で408トークン足りなくなり0.87にした。0.88以上は試していない。 4. 溢れたとき:追い出すか、黙って待たせるか 方式 エンジン 混んだとき遅れる場所 見える指標 追い出す vLLM・SGLang 生成の途中(ストリーミングが止まる) vllm:num_preemptions/sglang:num_retracted_requests_total 待たせる TensorRT-LLM 最初の1トークン(TTFT) 無い。numActiveRequestsで見る TensorRT-LLMは「入力長+max_tokens」ぶんを先に確保するので、max_tokensを大きく付けるほど同時に受け入れる本数が減る。並行数16で送っても中では8本しか走っていないことがある。空きに合わせて詰め込むMAX_UTILIZATIONもある(処理中のリクエストが一時停止しうる。試していない)。 今回は/metricsを2秒ごとに読み、numActiveRequestsの最大が送った本数に届いたことを確かめた。溢れる組み合わせは測っていない(3節)。 5. 観測:そのまま読むと誤判定になる エンジン 癖 対処 SGLang キャッシュヒットが0だとprompt_tokens_detailsごと出ない 無ければ0 SGLang num_retracted_requests_totalは初回の追い出しまで出ない ゲージnum_retracted_reqsがあれば0回 TensorRT-LLM kvCacheStatsはenable_iter_perf_statsが要り、1件処理するまで空 短いリクエストを1件送ってから読む TensorRT-LLM その1件で、測定の最初のリクエストがcached_tokens=1になった(<|im_start|>を共有。partial reuseが既定で有効なので、32トークンのブロックの一部でも当たる) 書式を通さない/v1/completionsで送る TensorRT-LLM 反復ごとの統計は読むたびに消える 測定中に2秒ごとに読む 「読めなければ0」にしていたら誤った結論を出していた。「読めなければ止める」作りにしていたので気づけた。 エンジン キャッシュの消し方 vLLM POST /reset_prefix_cache(VLLM_SERVER_DEV_MODE=1) SGLang POST /flush_cache TensorRT-LLM APIなし。再起動 キャッシュがどれだけ効くかは、送るケースの数とKVの容量で大きく変わる。今回は20ケース(入力の合計45,957トークン)を繰り返し送った。 チェックポイント KVの容量(トークン) 20ケースぶんが cached_tokens/入力(3エンジンとも同程度) bf16 26,813〜32,800 収まらず、一周する前に追い出される 3.6〜4.7%(当たるのは先頭の書式 約50トークンだけ) AWQ・FP8 76,096〜99,552 収まり、2周目からツール定義ごと当たる 並行数1で約75%、16で約82% 効いているのは並行数ではなく、量子化で空いたKVの容量である。量子化した構成の速さには、計算が軽くなった分とキャッシュに当たった分が混ざりうる。ケースの数・繰り返し方とKVの容量を並べて確かめたい。 6. 動かすまで 版がぶつかるので、仮想環境をエンジンごとに分け、クライアントはHTTPだけで話す。 エンジン torch transformers vLLM 2.13.0+cu132 5.17.0 SGLang 2.13.0 5.12.1 TensorRT-LLM 2.9.1+cu130 4.57.3 問題 対処 TensorRT-LLM環境でModelOptが動かない(NumPy<2固定と最新pyarrowが衝突) pyarrow<22 TensorRT-LLM FP8が起動中にsegfault(原因不明、再実行で通過) — そのとき親プロセスが残り、20分待ち続けた ログにSegfault encountered等が出たら即失敗にして1回だけ再起動 まとめ:比較を作る・読むときのチェックリスト 確かめること 確かめ方 モデルに届く入力は同じか usage.prompt_tokensを並べる。テンプレート適用後の文字列を比べる ツールパーサーは何か 起動オプションを書く 量子化の計算は何か 起動ログのカーネル名・警告 量子化のチェックポイントは同じか 対応表とソースで、使うGPUで読める形式か確かめる KVの容量はいくつか 割合ではなくトークン数で見る。揃えるなら直接指定する 溢れたら何が起きるか 追い出しカウンタと同時実行数。max_tokensも書く 指標が読めないときの扱い 読めなければ止める キャッシュの状態 有効か、区切りごとに消すか、cached_tokensはいくつか。ケースの数・繰り返し方とKVの容量も書く 私の使い分け(推奨案) 速さではなく、導入の手間・設定の素直さ・観測しやすさで決めた。 当てはまる範囲:24GBクラスのGPU1台・8B級・同時8本程度まで。大きいGPUや70B級ではメモリ(3節)と量子化の対応(2節)が変わる。 推奨 想定するユースケース 決め手 気をつけること vLLM 社内チャットやRAGのPoC。公開モデル(量子化版を含む)を差し替えて評価する 公開チェックポイントをそのまま読める(2)。既定の設定で起動し、追い出しがカウンタで見える(3・4) L4のFP8はW8A16(2) SGLang 同じツール定義・システムプロンプトを先頭に付けるエージェントやバッチ処理 RadixAttention(用語)。L4でもFP8がW8A8(2)。※今回は共有部分が短く、実測ではない(5) 入力が増える(1)。割合を手で決める(3)。指標が0で出ない(5)。共有部分の長い順に処理するlpmは既定ではない(既定fcfs) TensorRT-LLM モデルを固定して長く運用する本番API 処理中のリクエストが途中でやり直しにならない(4)。量子化は最初の1回で済む 量子化を作り直す(2)。混むとTTFTが伸びるので、SLAがTTFTなら自分で測る(4)。キャッシュを消すAPIなし(5) 迷ったらvLLMで基準を作り、チェックリストで条件を揃えてから他と比べる。 付録:起動コマンド vLLM 0.31.0(bf16。AWQ・FP8はモデル名だけ替える。VLLM_SERVER_DEV_MODE=1 を付けて起動) vllm serve Qwen/Qwen3-8B --served-model-name Qwen/Qwen3-8B \ --host 127.0.0.1 --port 8000 \ --enable-auto-tool-choice --tool-call-parser hermes \ --enable-prompt-tokens-details --max-model-len 8192 SGLang 0.5.21 python -m sglang.launch_server --model-path Qwen/Qwen3-8B --served-model-name Qwen/Qwen3-8B \ --host 127.0.0.1 --port 30000 \ --tool-call-parser qwen --enable-metrics --enable-cache-report \ --mem-fraction-static 0.87 --context-length 8192 TensorRT-LLM 1.2.1(ModelOptで作ったAWQ。bf16はQwen/Qwen3-8B) echo "enable_iter_perf_stats: true" > trtllm-serve.yaml trtllm-serve ~/models/Qwen3-8B-int4-awq-modelopt \ --host 127.0.0.1 --port 8001 --backend pytorch \ --tool_parser qwen3 --config trtllm-serve.yaml --max_seq_len 8192 出典 SGLang v0.5.21:github.com/sgl-project/sglang/tree/v0.5.21 (srt/entrypoints/openai/serving_chat.py・protocol.py・usage_processor.py、srt/observability/metrics_collector.py、srt/function_call/function_call_parser.py、srt/arg_groups/memory_hook.py) TensorRT-LLM v1.2.1:github.com/NVIDIA/TensorRT-LLM/tree/v1.2.1 (tensorrt_llm/commands/serve.py、llmapi/llm_args.py、_torch/model_config.py、_torch/modules/linear.py、cpp/tensorrt_llm/executor/jsonSerialization.cpp、docs/source/features/quantization.md) ModelOpt 0.37.0:github.com/NVIDIA/Model-Optimizer/tree/0.37.0 (examples/llm_ptq/hf_ptq.py) vLLM v0.31.0:github.com/vllm-project/vllm/tree/v0.31.0 (vllm/config/cache.py、vllm/engine/arg_utils.py、vllm/model_executor/layers/quantization/fp8.py、vllm/model_executor/kernels/linear/init.py・scaled_mm/marlin.py・cutlass.py・flashinfer.py・deep_gemm.py・b12x.py、vllm/utils/flashinfer.py、vllm/platforms/cuda.py、vllm/model_executor/layers/quantization/utils/w8a8_utils.py、csrc/libtorch_stable/quantization/w8a8/cutlass/c3x/、vllm/entrypoints/openai/chat_completion/protocol.py、vllm/v1/metrics/loggers.py)、起動ログ vLLM プレフィックスキャッシュの設計:docs.vllm.ai TensorRT-LLM スケジューラの方針(GUARANTEED_NO_EVICT/MAX_UTILIZATION):nvidia.github.io SGLang サーバー引数:docs.sglang.io 、new_token_ratioはsrt/managers/schedule_policy.py nvidia/Qwen3-8B-FP8:huggingface.co PagedAttention:Kwon et al., SOSP 2023。RadixAttention:Zheng et al., NeurIPS 2024
NCCL Roadmap Nov 2026 - Jan 2027 の解説 はじめに ! NCCL 奥がふかすぎる。。。。にゃ。。。 NVIDIA が公開した NCCL Roadmap: Nov 2026 - Jan 2027 (GitHub Issue #2486) から、大規模分散学習・推論に携わるエンジニアが押さえるべきポイントを解説します。前回のロードマップの解説は「NCCL Roadmap Aug-Oct 2026 の解説」、そこで挙げた項目の現在までの実装状況は「NCCL 2.32.3 でロードマップはどう実装されたか」にあります。GIN の基本は前回の解説を前提にします。 この記事では、新しい概念の GENK と Flow、AWS EFA と GIN の現状、注目機能 2 つを扱います。調査は 2026 年 10 月 29 日に行い、まだリリースされていないコードは、staging ブランチを次のリリースの候補とみなして読んでいます。 実装状況の確かめ方 NVIDIA/nccl、aws-ofi-nccl、amazon-contributing/DeepEP、NVIDIA/nccl-extensions の公開ブランチを手元に取得し、commit の履歴とソースを読んで確かめました。PR と Issue の状態 (open か、マージされたか、閉じた日) は GitHub で確かめています。記事のソースへのリンクは、確かめたときの commit に固定しています。 NCCL は master、staging、dev の 3 本のブランチを公開していて、staging には 2.32.5-1 の版番号を付けた commit があり、その後に 2.32.6a0 に上がっています。ロードマップは「Coming Soon: NCCL 2.32 Update 1」の版番号を書いていませんが、この記事では staging のコードをその候補として読みます。 今回のロードマップの項目のうち、この記事で扱うものと、前回のロードマップの近い項目は次のとおりです。対応は筆者の読みです。 今回のロードマップの項目 前回のロードマップの近い項目 General Kernels (GENK) Unify legacy kernels using Device API Flow Device API More Device API primitives GIN Backend Improvements GIN backend improvements CFT を使う階層型カーネル Compute Fabric Transport (CFT) through Device API New Enqueue Pipeline Legacy and symmetric-kernel host-side unification ロードマップには、内部のメモリの削減、RAS の進み具合の監視、RMA の profiling など、この記事で扱わない項目もあります。 Device API とは何か NCCL の API の全体像 ロードマップの項目を説明する前に、まず背景知識として NCCL の API を整理します。NCCL の API は、Host API、Device API、plugin API の 3 つに分けられます。Host API と Device API の仕様は NCCL の公式文書にあり、この記事の表はその章立てに沿っています。plugin API は公式文書の API の章には無く、リポジトリの plugins ディレクトリに plugin の種類ごとの説明があります。 API 何をするか 例 Host API CPU から呼んで、communicator (通信に参加する GPU の集まり) の作成や集団通信を NCCL に頼みます。集団通信では、NCCL がアルゴリズムを選び、NCCL のカーネルを GPU で起動します。利用者の計算は別のカーネルです ncclCommInitRank、ncclAllReduce Device API 利用者が書いた CUDA カーネルの中から、ほかの GPU との通信を直接呼びます (2.28 から)。計算と通信を 1 つのカーネルに入れられます。使う前の準備には Host API を使います GIN、LSA plugin API NCCL のほうから呼ぶ、下の部品の差し替え口です。ネットワークへの送受信や、アルゴリズムの選択の調整などを、NCCL の外の実装に任せます net、GIN。説明は plugins にあります。aws-ofi-nccl は EFA 用の plugin 誰が NCCL を呼ぶか NCCL を呼ぶのは、多くの場合アプリ自身ではなく、間に入るフレームワークやライブラリです。主な呼び手を、ソースで確かめた範囲で並べます。 呼び手 どう呼ぶか 使う API PyTorch の torch.distributed (backend "nccl") C++ の ProcessGroupNCCL が、ncclAllReduce などを直接呼びます Host API PyTorch の backend "nccl4py" (torch.distributed._nccl4py) Python で書いた backend が、nccl4py を通して呼びます。init_process_group("nccl4py") で選びます Host API nccl4py NVIDIA の公式の Python の bindings。Cython で libnccl を呼び、PyTorch の tensor や CuPy の配列をそのまま渡せます。実験的な CuTe DSL の部品もあります 主に Host API vLLM の PyNccl Python の ctypes で libnccl を読み込んで呼びます Host API PyTorch の symmetric memory の演算、DeepEP、NCCL EP 自分の CUDA カーネルの中から通信を呼びます。準備には Host API で window (次の節で説明します) を登録し、device communicator を作ります Device API と Host API aws-ofi-nccl など NCCL のほうから呼ばれます。NCCL が EFA などのネットワークに送るときの下の部品です。InfiniBand には NCCL に組み込みの経路もあります plugin API Device API は何のためにあるか Host API で通信を頼むと、通信は NCCL のカーネル 1 回分の単位になり、利用者の計算とは別のカーネルになります。計算の途中で決まった相手に少しずつ送りたい場合や、計算と通信を細かく重ねたい場合には、この単位が粗すぎます。たとえば MoE のトークンの振り分けでは、どのトークンをどの GPU に送るかが計算の途中で決まります。 Device API は、利用者のカーネルの中から、ほかの GPU のメモリへ直接データを動かせるようにするための API です。NCCL の Device API の例は、計算と通信を分けて順に行う従来のやり方に対して、カーネルの中で両方を細かく混ぜることで、待ち時間を減らし処理量を上げられると説明しています。 GPU のカーネルからほかの GPU に届く経路はいくつかあり、Device API は経路ごとに部品を用意しています。同じノードで NVLink や PCIe の P2P でつながった相手には、相手のメモリを普通の読み書きの命令で触れます (LSA)。NVLink のスイッチが複数の宛先への書き込みや足し合わせを引き受けられるシステムでは、Multimem を使います。ノードをまたぐ相手には、NIC を通して put (相手のメモリへの書き込み) や get (相手のメモリからの読み出し) を出します (GIN)。どちらも、相手が何も呼ばない片側通信です。CFT は、相手のメモリを番地 (ポインタ) で触る代わりに、CUDA が用意する宛先の番号 (logical endpoint) とオフセットを指定して put や get を出す部品で、Blackwell 以降の GPU で使えます。これらと組み合わせて使う、足し合わせやコピーの部品 (reduce と copy) も用意されています。 GIN の仕組みは、前回の解説の「GIN とは何か」で説明しました。LSA は後の GENK と Flow の節で、CFT は注目機能の節で扱います。Multimem と reduce と copy の部品は、この記事では詳しく扱いません。 どの部品を使う場合も、準備は利用者が CPU で Host API を呼んで行います。通信に使うバッファを、全ランクで同じ形に対応付けた領域 (symmetric memory window) として登録し、カーネルが使う資源を指定して device communicator を作り、それを渡して自分のカーネルを起動します。手順の例は NCCL の文書にあります。GIN を使うカーネルは、NCCL を更新したら作り直す必要があると文書は書いています。次の節からは、この Device API の上に加わる新しい部品とカーネルを見ていきます。 GENK と Flow とは何か ! GENK と Flow は、まだリリースされておらず staging と dev にだけあるコードです。この節は staging の実装から読み取った内容で、リリースまでに変わる可能性があります。staging で試すには、ビルドのときに SYM_GENK=1 を指定し、実行のときに NCCL_SYM_GENK_ENABLE=1 を設定する必要があります。 概要 Flow は、前の節の LSA と GIN の上に作られた、集団通信の部品を提供する Device API です。ロードマップは Flow を「LSA と GIN の両方で動く、高水準の Device API」と説明しています。LSA は前の節で触れた、相手の GPU のメモリを普通の読み書きの命令で触る部品です。 GENK (General Kernels) は、NCCL が自分の Ring と Tree の集団通信を、この Flow の上で作り直したカーネルの一群です。Ring では AllGather、ReduceScatter、Broadcast、Reduce、AllReduce を、Tree では AllReduce を実装しています。 前回のロードマップには「Unify legacy kernels using Device API」という項目がありました。筆者は、GENK をこの項目につながる取り組みと読んでいます。今回のロードマップは、選んだプラットフォームで GENK を既定にし、古いカーネルを置き換えるとしています。あわせて性能モデルを 1 つにまとめ、どの実装を選ぶかを同じ物差しで決めるとしています。 staging では、NCCL 自身が同じ Flow で集団通信を組んでいます。Flow がリリースに入れば、利用者も NCCL の内部と同じ部品で通信カーネルを書けます。 NCCL の集団通信には、GENK の前から 2 種類のカーネルがあります。 legacy kernels: NCCL に最初からある Ring や Tree のカーネルです。経路の違いは、CPU の側が準備する transport (NVLink や PCIe の P2P、ネットワークなど) が受け持ち、ネットワークでは CPU の proxy スレッドが NIC に渡します。 symmetric カーネル: 全ランクが同じ形で登録したバッファ (symmetric memory window) を集団通信に渡したときに使える、新しいカーネルです。相手のバッファの番地が分かるので、Device API の LSA などで相手のメモリを直接読み書きします。いまあるものは、NVLink の直結を前提に、場面ごとに作り込まれています (表の「特化した symmetric カーネル」)。 GENK は、symmetric カーネルと同じ Device API の作りで、legacy kernels と同じくらい広い経路で動くものです。Ring で隣の GPU にデータを渡す場面で比べると、次のようになります。 隣の GPU との経路 legacy kernels 特化した symmetric カーネル GENK NVLink 動く 動く 動く (LSA) PCIe の P2P 動く 使えない 動く (LSA) 別のノード (ネットワーク) 動く 一部の種類だけ動く (GIN) 動く (GIN) 表は、Hopper 以降の GPU で GENK を有効にした場合についてソースから読んだもので、動かして確かめたものではありません。PCIe の行は CUDA P2P が使える場合、GENK のネットワークの行は GIN Proxy (GPU の代わりに CPU のスレッドが NIC に送る GIN のバックエンド) が使える場合です。 特化した symmetric カーネルは、PCIe だけでつながったノードでは候補から外れます。GENK では、隣の GPU ごとに Flow が LSA か GIN かを実行時に選ぶので、同じカーネルのまま NVLink でも PCIe でもネットワークでも動きます。staging のカーネルの選択にも「特化したカーネルは NVLink の直結を要するが、GENK は CUDA P2P の LSA や GIN も使える」というコメントがあります。 利用者の側の例もあります。NCCL の Device API の例には、Transformer の RMSNorm の計算を ReduceScatter と AllGather の通信と 1 つのカーネルにまとめるものがあり、いまは LSA、Multimem、GIN を直接使って、受け取り、足し合わせ、送り出しを書いています。Flow は、NCCL 自身が集団通信に使うこの受け取り、足し合わせ、送り出しの部分を、部品として提供します。 NCCL の文書の Device API の章には、Flow と GENK の説明はまだありません。Flow の使い方は、ヘッダの説明にまとまっていて、Flow processor を足した commit が入口になります。GENK は、カーネルを足した commit と、NCCL の選択に組み込んだ commit が出発点です。 図で [API] のタグを付けた Flow processor、LSA connection、GIN connection は、アプリのカーネルからも呼べる Device API です。cost model、legacy kernels、specialized symmetric kernels、GENK は NCCL の中の部品です。 GIN の signal とは GIN の put は片側通信で、受け手は何も呼ばないので、データがいつ届いたかを自分では知れません。signal は、それを知らせるための、受け手の側に置かれた数です。送り手が put に signal を付けると、データが届いたあとに受け手の数が増え (たとえば 1 増え)、受け手はその数が目標に達するまで待ってから読みます。GIN の基本は、前回の解説の「GIN とは何か」で説明しました。 signal には strong と weak の 2 種類があります。weak signal が「読んでよい」と約束するのは、その signal を付けた put のデータだけです。strong signal は、同じ相手に同じ context (GIN の通信の通り道の 1 本) で先に出した put のデータもすべて読めると約束します。そのため、最後の put にだけ signal を付ければ済みます。 EFA の送り方の SRD は、書き込みが届く順番を保ちません。そのため、最後の put に付けた signal が届いても、前の put のデータが届いたとは限りません。EFA の上の経路のうち、GPU から直接送る EFA GDA が提供するのは weak signal だけです (次の節で説明します)。一方、Flow の GIN の接続は strong signal を必要とします。 AWS EFA 関連アップデートと現在の課題 EFA の 2 つの GIN の経路と signal の種類 ! GENK と Flow の GIN の接続は strong signal を必要とします。EFA の上で strong signal を提供するのは GIN Proxy だけで、GPU から直接送る EFA GDA は weak signal しか提供しません。そのため、EFA の複数ノードで GENK を使うなら Proxy の経路になります。 EFA で GIN を使うときは、使う経路 (GIN のバックエンド) が、カーネルの必要とする種類の signal を提供するかを先に確かめます。NCCL は、カーネルが求める種類の signal を提供できないバックエンドを使わず、提供できる別のバックエンドがあればそちらに切り替え、無ければエラーにすると説明しています。EFA で使う GIN の経路として、ここでは 2 つを比べます。1 つは NCCL の GIN Proxy で、CPU のスレッドが aws-ofi-nccl の RMA の機能を通して EFA に送ります。もう 1 つは EFA GDA で、GPU のカーネルが EFA の送信キューに直接依頼を書きます。aws-ofi-nccl の文書によると、EFA GDA は NCCL_GIN_TYPE=5 で明示したときに使われます。同じ文書は、明示しなければ NCCL 2.31 は EFA で Proxy を使うと書いています。インスタンスやドライバなどの要件も同じ文書にあります。 2 つの経路は、提供する signal が違います。NCCL の Proxy は、strong signal と VA signal (登録した仮想アドレスに置く signal) を使えると報告します。EFA GDA については、aws-ofi-nccl が strong signal も VA signal も使えないと報告し、NCCL の側も番号で指す indexed signal だけを受け付けます。EFA GDA の signal は、EFA の NIC が届いた書き込みを数えるカウンタで作られています。aws-ofi-nccl の文書は、signal と counter の合計が EFA のデバイスあたり 512 個までであることも、制限に挙げています。 図の矢印は、使い手が必要とする signal をバックエンドが提供するかを示します。矢印はソースと文書から描いたもので、どの経路が選ばれるかや、動かした結果を示すものではありません。図の DeepEP の ordered は届く順番を前提にする上流のカーネル、unordered は順番を前提にしない amazon-contributing のフォークのカーネルです。 GENK はノードをまたぐ通信に使う GIN のバックエンドとして Proxy を指定しているので、staging のコードを読む限り、GENK が EFA の上で複数ノードの通信をするなら Proxy の経路を通ります。一方、aws-ofi-nccl の文書は、EFA GDA を使うときに symmetric の GIN カーネルを無効にするよう書いていて、この設定 (NCCL_SYM_GIN_KERNELS_ENABLE=0) にすると複数ノードの GENK は候補から外れます。つまり、EFA GDA を使う設定 (NCCL_GIN_TYPE=5 と NCCL_SYM_GIN_KERNELS_ENABLE=0) では複数ノードの GENK は使われず、複数ノードで GENK を試すときは、この 2 つの環境変数を設定せずに Proxy の経路で動かします。 aws-ofi-nccl v1.22.0 で、EFA GDA の Get がタグの付いた版で使えるようになった Get は、GPU のカーネルが、ほかの GPU のメモリの中身を、持ち主に何もさせずに読み出す操作です。たとえば、手元の番号を見て、ほかの GPU が持つ表の行を取り出すような使い方ができます。Get の意義は「NCCL 2.32.3 でロードマップはどう実装されたか」で説明しました。 その記事の時点では、NCCL 2.32.3 には Get が入っていましたが、aws-ofi-nccl の側の対応はタグの無い master にしかありませんでした。2026 年 10 月 22 日 (日本時間) の v1.22.0 は、NCCL 2.32 が使うキューの形への対応と、Get の完了を数える修正を含む最初のタグです。これで、NCCL 2.32 と aws-ofi-nccl v1.22.0 という、タグの付いた版どうしに、EFA GDA の Get に要る変更がそろいました。v1.22.0 のリリースノートは Get に触れておらず、筆者は動かして確かめていません。 ただし、EFA GDA の Get には、完了を知らせる signal や counter を付けられません。読み出しが終わったかは、出した依頼をまとめて待つ Flush で確かめます。 MoE の通信ライブラリ (DeepEP と NCCL EP) の動き MoE のトークンを担当の expert に配り (dispatch)、計算結果を集める (combine) 通信のライブラリにも動きがありました。DeepEP と NCCL EP はこの同じ役割の候補で、どちらも下で NCCL を使います。DeepEP は 2026 年 9 月 29 日の V2.5 で NVSHMEM への依存を取り除き、ノードをまたぐ通信は NCCL の GIN だけになりました。要件は NCCL 2.32.3 以降です。EFA で DeepEP を動かす取り組みは amazon-contributing/DeepEP のフォークにあり、その詳細は別の記事で整理してみます。 NVIDIA の NCCL EP は NVIDIA/nccl-extensions に移り、NCCL の staging からは、nccl_ep と nccl_m2n のディレクトリが消されました。 注目機能 2 選 CFT を使う階層型カーネル CFT (Compute Fabric Transport) は NCCL 2.31 で Device API に加わった部品で、嬉しさは 2 つあります。1 つ目は、カーネルから直接届く相手を、LSA の範囲より広げられる見込みがあることです。LSA で届く相手は、CUDA P2P で相手のメモリを普通の読み書きの命令で触れる相手 (LSA team) に限られます。CFT は相手を番地ではなく CUDA が配る宛先の番号で指すので、届く範囲は、CUDA が「この番号で届く」と報告する範囲で決まります。前回のロードマップも、CFT を、logical endpoint で届く範囲の相手を宛先の番号で指す通信の形と説明していました。ただし、リリースされた版では、CFT で届く相手の集まり (CFT team) の大きさは既定で LSA team と同じです。 2 つ目は、受け手に届いたことを早く知らせる書き込みがあることです。CFT の普通の put や reduce で受け手に知らせるときは、データを書いたあと、書き込みの完了を確定させ (fence)、合図のフラグを書きます。これが counted な書き込みで、フラグの代わりに、NCCL_WIN_CFT_COUNTED を付けて登録した window の中のカウンタを使います。カウンタはデータより後に進むことが保証されているので、送り手は fence とフラグの書き込みを省けます。staging の文書は、これを低遅延のための操作と説明しています。受け手は、カウンタを見てからデータを読む間に fence を入れる必要があります。 仕組みは次のとおりです。CFT の宛先の番号は、CUDA 13.3 で加わった logical endpoint という仕組みです。NCCL は CPU の側で、宛先の番号を確保し、そこに各 GPU のバッファを結び付けておきます。カーネルは、送りたいデータを shared memory に置き、宛先の番号とオフセットを指定して put を出し、そのあと完了を確かめます。 dev では、CUDA 13.4 以降で CUDA が「logical endpoint で届く範囲」を報告すると、CFT team をその範囲から作ります。その範囲は LSA の範囲の倍数でなければならず、LSA より広い範囲を 1 つの CFT team にできる作りです。NCCL の文書は、CFT がどの物理的な経路を通るかを書いていません。NCCL EP の CFT を使う経路は、1 つの NVLink のドメインの中だけを対象にしています。 ロードマップは、この CFT を使って、AllGather、ReduceScatter、AlltoAll の階層型カーネルを作るとしています。階層型は、近い GPU の間と遠い GPU の間で通信を分ける作りで、LSA の範囲と、それより広い CFT team の範囲を使い分けることになると筆者は読んでいます。master、staging、dev を調べた範囲では、CFT の宛先の集まりの階層の種類は 2.32.3 のコードに定義されていますが、それを使う集団通信のカーネルは見当たりませんでした。 CFT とは CFT が使えるのは、ハードウェア、ドライバ、CUDA が logical endpoint と fabric 用の命令に対応したシステムだけで、例の barrier のプログラムは CUDA 13.3 と SM_100 を要件に挙げています。CFT team を CUDA の報告する範囲 (clique) から作る変更は、dev の 2026 年 9 月 28 日の commit で入り、master と staging にはまだありません。 NCCL EP には、CFT の counted な書き込みで低遅延の dispatch を送る経路が、NCCL_EP_LL_CFT=1 で選んだときだけ使われる形で、2026 年 9 月 16 日の commit から main に入っています。 New Enqueue Pipeline: 呼び出しから起動までを 3 つの層に分ける enqueue は、アプリが呼んだ集団通信や put を、カーネルやコピーの起動に変える NCCL の中の流れです。ロードマップは、これを管理 (Management)、準備 (Preparation)、スケジューリング (Scheduling) の 3 層に分け、制御の流れを簡単にし、カーネルの最適化をしやすくするとしています。 管理と準備の層を含む基本の枠組みは 2.31.2 から入っていて、その時点のスケジューリングの層は既存の経路に戻すだけでした。いまの staging と dev でも、新しい流れは既定で無効です。dev では、Preparation の tuning ですべての集団通信のアルゴリズムやプロトコルを先に選び、classification でその結果だけを使って起動の経路を振り分ける形にしました。経路ごとのスケジューラは、それぞれの仕事を stream ごとに 1 つのキューにまとめます。dev の commit では、新しい流れの symmetric のスケジューラが起動するのは特化した symmetric カーネルだけで、GENK は自分用の device communicator の準備が要るので、従来の流れの symmetric カーネルのスケジューラから起動されます。 まとめ staging の GENK は、Hopper 以降の GPU でビルドと環境変数で有効にすると NVLink、PCIe、ネットワークのどの経路でも同じカーネルが候補になり、EFA の複数ノードでは strong signal を提供する GIN Proxy の経路を使います。CFT を使う階層型カーネルは部品だけが公開コードにあり、New Enqueue Pipeline は枠組みが既定で無効のまま dev で進んでいます。
⚡ Serve LLMs faster and cheaper with vLLM's PagedAttention, continuous batching and a drop-in OpenAI-compatible API. 🔗 vllm-project/vllm Credits: vllm-project/vllm #vllm #python #llm
一応注意喚起, 少なくともvLLM v0.30でFA4を使うと本来出る速度の0.4倍ぐらいの速度しかでなくなってしまうので, version up推奨です vllm#59362
FlashAttentionの速度が明らかにおかしくてこれ多分バグだろvLLMにコントリビュートチャンスと思ったら2026/09/08に既にPRは投げられており, 2週間前にclose, それを昨日気づいて車輪の再発明をしたという形だった。vLLM contributeチャンスを逃し悔しい vllm#55866
Numbers from the open vLLM RFC: vllm#63626