Continuum docs
Continuum \ Advanced inference

Durability costs a model call 16 to 21 ms.

A model call made directly against the same call inside a Temporal workflow, 200 runs per arm on LM Studio and vLLM, then the same prompt through the whole Continuum platform on LM Studio, span by span.

UPDATED 2026-09-26 · VERSION 0.3

The benchmark

The benchmark runs two tests against the same model call made directly. The first puts a Temporal workflow around the call and nothing else, so what it adds is durability's cost alone. The second sends the same prompt through the whole Continuum platform, which does far more than run the call, and measures what a real request costs. The lead figures come from 200 runs per arm in the runtime comparison, the first test on LM Studio and on vLLM and the second on LM Studio, and every run is kept with its setup. They replace the "3.5% at p99" the site quoted for months with no run behind it.

Like for like

Durability adds 16 to 21 ms to a model call, and its own time at p99 is 25 to 32 ms.

The first test isolates Temporal. Both arms make the same streamed call, with the same prompt, the same model and the same client pipeline, on one machine. They take turns run by run, so any drift in the model's speed lands on both.

  • Client → model. The call made directly, timed from the request to the last token.
  • Client → Temporal → model. The same call inside a workflow of one activity, with the answer returned as the workflow's result. Timed from starting the workflow to the result in hand. Nothing of Continuum's is in the path: no Gateway, Postgres, Redis, Relay or fetch.
Durability's own time: each durable run's time outside the model call, 200 runs per arm
LM Studio, short prompt88-token answer
p50 13 · p95 24 · p99 25 ms
vLLM NVFP4, short prompt137-token answer
p50 13 · p95 24 · p99 25 ms
LM Studio, long prompt658-token answer
p50 14 · p95 26 · p99 30 ms
vLLM NVFP4, long prompt700-token answer
p50 14 · p95 24 · p99 26 ms
LM Studio, 4K document358-token answer
p50 16 · p95 28 · p99 32 ms
vLLM NVFP4, 4K document257-token answer
p50 16 · p95 27 · p99 28 ms
Table view
Runtime and promptp50p95p99Runs
LM Studio, short prompt, 88-token answer 13 ms 24 ms 25 ms 200
vLLM NVFP4, short prompt, 137-token answer 13 ms 24 ms 25 ms 200
LM Studio, long prompt, 658-token answer 14 ms 26 ms 30 ms 200
vLLM NVFP4, long prompt, 700-token answer 14 ms 24 ms 26 ms 200
LM Studio, 4K document, 358-token answer 16 ms 28 ms 32 ms 200
vLLM NVFP4, 4K document, 257-token answer 16 ms 27 ms 28 ms 200
Short promptLong prompt4K document
The answer, LM Studio / vLLM88 / 137 tokens658 / 700 tokens358 / 257 tokens
LM Studio: the call, p50293 ms1,911 ms1,122 ms
LM Studio: Temporal adds, p5017 ms (5.9%)19 ms (1.0%)19 ms (1.7%)
vLLM NVFP4: the call, p50625 ms3,072 ms1,162 ms
vLLM NVFP4: Temporal adds, p5016 ms (2.6%)18 ms (0.6%)21 ms (1.8%)

Each arm ran 200 times, on two runtimes and three prompts: LM Studio running the GGUF build on Windows, and vLLM running an NVFP4 build of the same model in WSL2. Durability's time outside the call didn't move with the runtime or the length of the answer: 13 ms at p50 for an 88-token answer, 14 ms for a 658-token one. The 4K document adds 2-3 ms, because the workflow's input carries its 17 KB prompt and Temporal persists it.

The tail is measured outside the model call.

Every run's Temporal history records when the activity started and finished, so the time the client spent outside it is durability's alone: the start, the dispatch to a worker, the result coming back, and the client's call to collect it. The 2 to 4 ms the activity spends around the model call counts with the call, and the model's own spread can't show up in what is left.

The 200 durable runs behind the percentile strip's LM Studio long-prompt row, played back in order. Each request goes out along the top rail to Temporal, the worker and the model, and comes back along the rail beneath it. The readout counts only while the request is outside the model call, and each run's time outside it lands in the histogram. The p50 and p95 settle as the runs arrive, and the p99 is drawn once all 200 are in.

At 200 runs the nearest-rank p99 is the 198th value, so no single slow run sets it. Measured this way, durability's p99 is 25 to 32 ms on every runtime and prompt: 30 ms on LM Studio, 1.5% of a 658-token answer, and 26 ms on vLLM, 0.8% of a 700-token one. Measured end to end instead, each durable run against the direct calls' fit, the long prompt's p99 is 116 ms on LM Studio and 416 on vLLM, which is the model's own spread: its direct calls alone ranged over 191 ms and 527. One run in 1,200 spent more than 100 ms outside the call, LM Studio's 4K run 126, whose workflow start took 433 ms, which fits the reconnect stall under Full service.

Temporal's time is three hand-offs, whatever the model writes.

From Temporal's historyp50p95p99
Workflow started to activity started5 ms15-16 ms16-18 ms
Activity completed to workflow completed5 ms5-8 ms8-13 ms

Durability's 13 to 17 ms outside the call at p50 is the start, the dispatch, the result coming back, and about a millisecond for the client's call to collect it. The table has the dispatch and the return as Temporal's history records them; the start and the collect are the client's own calls. Inside the activity it adds 2 to 4 ms beyond the model call, for starting the activity and returning its result.

A long answer doesn't make any of it bigger, which is why on LM Studio its share falls from 5.9% of an 88-token answer to 1.0% of a 658-token one. Eager workflow start would trim the dispatch by about 3 ms, but only for a process that hosts a worker, and a worker has no place in the Gateway.

The first durability runs: 20 and 30 per arm, LM Studio only

Taken the morning of 2026-09-25, before the runtime comparison, with the inference capability's sampling (temperature 0.7, 4,096 tokens) and LM Studio at one slot, then four. At 20 or 30 runs a p99 is the slowest run, so these give the slowest run by name.

80 tokens, 1 slot730 tokens, 1 slot730 tokens, 4 slots
Runs302020
Client → model, p50289 ms2,392 ms2,456 ms
Client → Temporal → model, p50311 ms2,407 ms2,474 ms
Temporal adds, p5021 ms (7.3%)14 ms (0.6%)15 ms (0.6%)
Outside the activity, p5015 ms16 ms19 ms
Outside the activity, p9525 ms43 ms51 ms
Outside the activity, slowest run35 ms (12.1%)55 ms (2.3%)52 ms (2.1%)

Full service

Through the whole platform, a request costs 35 to 46 ms more.

The second test sends the same prompt through everything the durable inference page describes: the Gateway's auth, session and writes to Postgres and Redis, the workflow, the answer streamed into Redis as the model writes it, the Relay's notice over SignalR, and the fetch. It ran on LM Studio, 200 runs per arm, because the platform is wired to LM Studio, and at the inference capability's own sampling (temperature 0.7, 4,096 tokens), which is why its answers run 80, 728 and 328 tokens.

Short prompt, 80-token answerLong prompt, 728-token answer4K document, 328-token answer
The call, p50301 ms2,502 ms1,015 ms
The platform adds, p5035 ms (11.5%)46 ms (1.9%)37 ms (3.6%)
The 202 Accepted, p5012 ms12 ms13 ms
The platform's time outside the model's activity, 200 runs per arm on LM Studio
Short prompt80-token answer
p50 25 · p95 36 · p99 40 ms
Long prompt728-token answer
p50 28 · p95 43 · p99 344 ms
4K document328-token answer
p50 25 · p95 35 · p99 38 ms
Table view
Promptp50p95p99Runs
Short prompt, 80-token answer 25 ms 36 ms 40 ms 200
Long prompt, 728-token answer 28 ms 43 ms 344 ms 200
4K document, 328-token answer 25 ms 35 ms 38 ms 200

The model call dwarfs everything around it.

Every host exports its spans to the Aspire dashboard, and one trace holds the Gateway's handler, the worker's workflow and activities, and the Relay's handler together. Drawn to scale, each stretch at its own p50, Continuum is two slivers around the model call. The medians sum to 33 ms; measured end to end against the direct call, the platform adds 35 ms on the short prompt and 46 on the long one. The short and long runs' spans agree to within a millisecond everywhere but the call.

A request through the whole platform, each stretch at its p50, an 80-token answer
Gateway 11.6dispatch 5.0read input 1.4fetch 3.0Relay 0.9notice 5.9end markers 5.4model call 301 ms 0100200300 ms
  • Gateway11.6 ms
  • dispatch5.0 ms
  • read input1.4 ms
  • model call301 ms
  • end markers5.4 ms
  • notice5.9 ms
  • Relay0.9 ms
  • fetch3.0 ms

Continuum 33 ms of 335, both ends together. Temporal is 14 of them: the start, 2.8; the dispatch, 5.0; the workflow task before the notice, 5.9.

The same request with a 728-token answer, to the same rules
model call 2,502 ms 01,0002,000 ms
  • Gateway11.6 ms
  • dispatch5.0 ms
  • read input1.4 ms
  • model call2,502 ms
  • end markers5.4 ms
  • notice5.9 ms
  • Relay0.9 ms
  • fetch3.0 ms

Continuum 33 ms of 2,535, too thin to point at.

What each stretch is
Stretchp50What happens
Gateway, to 20211.6 msAuth and session from the Redis cache; the thread and both messages in Postgres, with the prompt going into Redis alongside; StartWorkflow, 2.8 ms
Dispatch5.0 msTemporal's workflow task, with the activity handed over in its response
Read input1.4 msThree Redis calls in sequence: check the prompt exists, clear a retry's output, read the prompt
The model call301.4 msThe same as calling the model directly; 2.1 ms of it before the response headers
End markers5.4 msThe last chunk and the end markers to Redis
Notice5.9 msTemporal: the activity's completion, a workflow task, then the notice as a local activity
Relay0.9 msThe Relay's handler, then SignalR to the client
Fetch3 msThe output from the Gateway, on the client's clock

Against durability alone, the platform adds the Gateway's auth, session and writes (about 12 ms, of which StartWorkflow's 2.8 is durability's own), the notice as a Temporal local activity and the Relay (about 7), and the fetch (about 3). The 5.9 ms before the notice is kept on purpose. Moved inside the activity, the notice would go out sooner but best-effort; as a local activity, Temporal retries it if it fails.

Three levers are still open, with the saving the spans predict: the thread and both messages in one statement (about 4 ms), the end-of-stream Redis calls in one batch (about 4), and a single read before the model call (about 1).

The platform's slowest runs are Temporal calls that stall about every five minutes.

Twelve of the 600 platform requests ran more than 100 ms over their median, and every one was a Temporal RPC taking 317 to 447 ms: the Gateway's StartWorkflow, the worker's dispatch, or the activity's completion. Each of those traces has a normal model span. They come about every five minutes per connection, which is when Temporal's frontend closes a client connection (frontend.keepAliveMaxConnectionAge, 5 minutes ± 10%). On the long prompt they set the p99 outside the activity, 344 ms. The short prompt and the 4K document held two each, and nearest rank leaves both past the p99.

The suspected cause is localhost, as in the two-second stall under Fine tuning. Every host reaches Temporal at localhost:7233, and the Temporal SDK's gRPC runs in its Rust core, which the .NET setting under Fine tuning doesn't reach. For a new connection it tries IPv6 first and waits 300 ms before IPv4, which fits the 317-335 ms stalls; the completions' 417-447 ms fit less well.

The Lightning runs of 2026-09-26 made their durable calls with Temporal pinned to 127.0.0.1:7233. The same stall came back at 113 to 129 ms, every time in the workflow's start: 6 of their 1,400 durable runs spent 102 to 113 ms starting the workflow, against a median of 3. All six were on the long prompts, the only runs that lasted past five minutes. That fits the pin removing the 300 ms IPv6 wait and leaving the reconnection itself, and the platform's 317 to 447 ms above is the cost without the pin.

The first platform runs: 20 and 30 per arm, LM Studio only
RunThe call, p50The platform adds, p50Outside the activity, p50p95Slowest run
80 tokens, 1 slot, 30 runs278 ms38 ms (13.6%)26 ms37 ms38 ms
80 tokens, 4 slots, 20 runs302 ms36 ms (12.0%)27 ms34 ms35 ms
80 tokens, 4 slots, 30 runs302 ms33 ms (11.0%)25 ms34 ms35 ms
730 tokens, 1 slot, 20 runs2,391 ms37 ms (1.5%)29 ms37 ms40 ms
730 tokens, 4 slots, 20 runs2,466 ms16 ms (0.7%)37 ms342 ms349 ms

The last run's tail is two of those reconnect stalls, back to back. One more long run is left out: from its ninth run on, every other direct call waited two seconds for a new connection, the localhost stall under Fine tuning.

Fine tuning

Measured one change at a time, the platform went from 168 ms to 38.

The first run through the platform added 168 ms to the short answer. Each change after it was measured on its own before the next one went in.

ChangeAdded to the short answer, p50What moved
As first built168 ms (64.7%)The 202 in 36 ms, and 50 ms on every task-queue hand-off
Eager activity execution133 ms (49.0%)One hand-off fewer per activity; the p95 from 398 ms to 160
One writer coalescing the reply133 ms (48.7%)The long answer's fetch, from 46 ms to 5: one stream entry per 50 ms instead of one per token
The Gateway's writes issued together138 ms (50.9%)The 202, from 33 ms to 29
Temporal's dev server tuned38 ms (13.6%)The 50 ms hand-offs gone: one remote activity from 200 ms to 11
Four slots and the settings cache fixed, together36 ms (12.0%)One Postgres read fewer per request

On the dev server as it ships, every task-queue hand-off costs 50 ms.

Timing trivial workflows, with no Continuum code in them, found why the early runs were slow. The Aspire AppHost now starts Temporal with four settings, each measured on its own, and every run in Like for like and Full service ran on it.

  • Eager activity execution (system.enableActivityEagerExecution). The worker receives the activity in the response to the workflow task that scheduled it, one hand-off fewer. It is off by default on the dev server.
  • SQLite in WAL mode (journal_mode=wal, synchronous=normal). The default journal synced on every commit, 25-50 ms a hand-off. WAL at normal survives a process crash and can lose the last transactions on a power loss, a trade made for a dev server only.
  • The transfer queue's poll rate (history.transferProcessorMaxPollRPS=1000). The 50 ms was the history service's reader moving new tasks to matching, limited by default to 20 polls a second. At 1,000, one remote activity fell from 100 ms to 11.
  • One task-queue partition (matching.numTaskqueueReadPartitions and …WritePartitions, both 1). With the 50 ms gone, 1 run in 100 stalled for two seconds on a partition no poller was covering. The p99 went from 2,010 ms to 26. One partition suits one worker, and caps a queue's throughput across many.

These settings tune a single-node dev server. A production cluster persists to Postgres or Cassandra and runs partitions for throughput across many workers, so a cluster gets measured on its own.

localhost cost two seconds a connection.

On this machine every store listens on 127.0.0.1 only, and LM Studio on IPv4 only. A new .NET connection to localhost tries IPv6 first, nothing answers on ::1, and Windows takes about two seconds to give up and fall back. The connection pools hid it, since they pay it only when a connection opens. LM Studio drops a connection after about four idle seconds, so every model request after a quiet spell paid two extra seconds, and a Postgres connection opened cold took 4.1 seconds.

Every connection to the stores and the model now uses 127.0.0.1, and the AppHost sets DOTNET_SYSTEM_NET_DISABLEIPV6=1 on every project it starts. Temporal's client connects from the SDK's Rust core, which that setting doesn't reach, so it still dials localhost (see Full service). That is for the dev stack only: on an IPv6-only network it would break name resolution, and a deployment configures its own endpoints.

By the book

Every run is taken the same way, and can be repeated from its setup.

  • Interleaved. Warmups first, then the two arms alternate, one request in flight on the whole machine, so drift in the model's speed lands on both.
  • Equal output. Each run is compared with a direct call of the same length, from a Theil-Sen fit across the direct runs, so one slow run can't move it. At the fixed seed each runtime repeats one answer, so the two arms are compared at identical output.
  • Checked before it starts. A preflight confirms the loaded model and its slot count match the configuration, and every answer is checked against the configured model.
  • The tail outside the model. Each run's time outside the activity, from its Temporal history, is the tail measure, and a p99 is quoted only from 200 runs or more.
  • Spans. Every host exports its OpenTelemetry spans to the Aspire dashboard, whose telemetry API returns one trace per request.
MachineAMD Ryzen 9 9950X3D (16 cores), 64 GB, RTX 5090 (driver 617.14); Windows 11 Pro, Docker Desktop on WSL2
LM Studio0.4.25 (llama.cpp 2.45.0): nvidia-nemotron-3-nano-4b, Unsloth GGUF Q4_K_S, 16,384 context, one slot, flash attention, seed 42
vLLM0.29.0 in WSL2: an NVFP4 build of the same model, one sequence, 16,384 context, KV cache bf16, Mamba state f32, seed 42
SamplingTemperature 0.6, top-p 0.95, top-k 20, min-p 0, repeat penalty 1.0, 8,192 max tokens, thinking on. The platform runs use the inference capability's own: temperature 0.7, 4,096 tokens
TemporalThe dev server, Server 1.29.0, pinned by image digest, restarted fresh, with the four settings under Fine tuning
PlatformEvery host a Release build under the Aspire AppHost, the stores in their containers
PromptsShort: "Count from 1 to 20, separated by spaces. Nothing else." Long: "Explain, in about 500 words, how Redis Streams differ from Redis pub/sub, and when to use each." The 4K document: a fixed 4,089-token text with a two-sentence answer
Runs200 per arm after 10 warmups, per runtime and prompt
# the model, as the study loaded it: one slot
dotnet run -c Release --project src/Apps/Continuum.Console -- models up --model nvidia-nemotron-3-nano-4b --parallel 1

# like for like: the call directly, then inside a workflow of one activity
dotnet run -c Release --project src/Apps/Continuum.Console -- benchmark durable-inference --runs 200 --warmup 10 --sampling "temperature=0.6,top_p=0.95,top_k=20,min_p=0,repeat_penalty=1,seed=42,max_output_tokens=8192" --prompt "Explain, in about 500 words, how Redis Streams differ from Redis pub/sub, and when to use each."

# the same against vLLM in WSL2
dotnet run -c Release --project src/Apps/Continuum.Console -- benchmark durable-inference --runs 200 --warmup 10 --sampling "…" --prompt "…" --endpoint http://127.0.0.1:8000 --model nvidia-nemotron-3-nano-4b-unsloth-nvfp4 --runtime vllm

# the whole platform, span by span
dotnet run -c Release --project src/Apps/Continuum.Console -- benchmark inference --runs 200 --warmup 10 --headless

# Temporal's own floor: trivial workflows, no Continuum code
dotnet run -c Release --project src/Apps/Continuum.Console -- benchmark temporal-floor --runs 50

A number compares only with one taken on the same machine, model and server settings. Docker Desktop's VM sits between the hosts and the stores here, and a Linux host wouldn't have it. A percentage depends on the answer's length: the same 16 ms is a smaller share of a slower answer and a larger share of a faster one. The runtime comparison's own setup, including how the NVFP4 build was made, is on its page.