Skip to content

Inference Engines

Local backend only

This page applies to the local backend only. When using the Tinker backend, the Tinker service handles all inference internally -- the [inference] config section is ignored. See Backends for Tinker setup.

retrain separates inference (sampling completions) from training (gradient updates). The inference engine controls how completions are generated, while PyTorch/PEFT always handles LoRA training.

Architecture

retrain
  └── LocalTrainHelper
        ├── InferenceEngine (ABC)
        │     ├── PyTorchEngine     ← same model, shared VRAM
        │     ├── MAXLocalEngine    ← in-process MAX pipeline
        │     ├── MAXServeEngine    ← HTTP to max serve
        │     └── OpenAIEngine      ← HTTP to vLLM / SGLang / TensorRT-LLM / MLX-LM / any server
        └── PyTorch/PEFT training (unchanged)

All engines implement the same interface: generate() returns token IDs + per-token logprobs. The training side never knows which engine produced the samples.

Engine options

Engine TOML value What it does
PyTorch pytorch Shares the training model for inference. 1x VRAM. Default
MAX (auto) max In-process if no URL, HTTP to max serve if url set
vLLM vllm HTTP client to a vLLM server
SGLang sglang HTTP client to a SGLang server
TensorRT-LLM trtllm HTTP client to a trtllm-serve server
MLX-LM mlx HTTP client to a local mlx_lm.server endpoint
OpenAI openai HTTP client to any OpenAI-compatible endpoint

For vllm and sglang, retrain sends prompt token IDs directly in /v1/completions requests. This avoids local token decode followed by server re-tokenization. If a server rejects token prompts with a 400/422 response, retrain falls back to the text prompt path and increments engine_token_prompt_fallbacks.

For trtllm, retrain sends the active LoRA adapter path in each completion request using TensorRT-LLM's lora_request field. The current trtllm-serve documentation shows text prompts for /v1/completions, so retrain does not assume token-ID prompt support until a server version proves it.

For mlx, retrain sends the active LoRA adapter path in each completion request using the MLX-LM adapters field.

Why PyTorch is the lowest-VRAM 1-GPU path

With LoRA training, only the adapter weights change -- the base model is frozen. The PyTorch engine exploits this: the same model object serves both training and inference. There is no weight duplication.

Every other engine loads a separate copy of the base model -- either in a different framework (MAX) or a different process (vLLM, SGLang, TensorRT-LLM, MLX-LM). On 1 GPU, that means 2x base model VRAM. Whether a server engine is faster enough to justify that cost is workload-dependent and should be decided by the benchmark sweep.

PyTorch (1 GPU):     [base model + LoRA]  ← shared, 1x VRAM
MAX (1 GPU):         [base model + LoRA]  +  [base model (MAX)]  ← 2x base VRAM
vLLM (1 GPU):        [base model + LoRA]  +  [base model (vLLM)] ← 2x base VRAM
TensorRT-LLM (1 GPU): [base model + LoRA] +  [base model (trtllm)] ← 2x base VRAM

Multi-GPU: when to use MAX / vLLM / TensorRT-LLM

With multiple GPUs, inference and training run on separate devices. Base model duplication is expected and desirable -- each device has its own copy.

8x H100 example:
  GPUs 0-6:  max serve (tensor parallel inference, continuous batching)
  GPU 7:     PyTorch/PEFT training

Here MAX, vLLM, SGLang, or TensorRT-LLM provide real benefits: tensor parallelism across inference GPUs, continuous batching for high throughput, and optimized kernels.

Quick start

# 1 GPU -- PyTorch (default, no extra setup)
retrain --devices gpu:0

# 1 GPU -- explicit PyTorch
retrain --devices gpu:0 --inference-engine pytorch

# 8 GPUs -- MAX serve on GPUs 0-6, training on GPU 7
max serve --model Qwen/Qwen3-4B-Instruct-2507  # manages its own GPUs
retrain --devices gpu:7 \
    --inference-engine max --inference-url http://localhost:8000

# 8 GPUs -- vLLM server
vllm serve Qwen/Qwen3-4B-Instruct-2507 --tensor-parallel-size 7
retrain --devices gpu:7 \
    --inference-engine vllm --inference-url http://localhost:8000

# 8 GPUs -- TensorRT-LLM server
trtllm-serve Qwen/Qwen3-4B-Instruct-2507 --tp_size 7
retrain --devices gpu:7 \
    --inference-engine trtllm --inference-url http://localhost:31000

# Apple Silicon -- MLX-LM local server
pip install -e ".[mlx]"
python -m mlx_lm.server --model mlx-community/Qwen2.5-3B-Instruct-4bit
retrain --devices cpu \
    --inference-engine mlx --inference-url http://localhost:8080

One-GPU external-engine comparison

On one GPU, external inference servers duplicate the frozen base model: the trainer has one copy and the server has another. Treat vLLM/SGLang on one GPU as an empirical A/B test against the shared-model PyTorch path, not as an automatic upgrade.

NVIDIA Dynamo is an orchestration layer above engines such as vLLM, SGLang, and TensorRT-LLM. It is relevant when retrain grows to multiple inference replicas, multi-GPU or multi-node serving, KV-aware routing, or disaggregated prefill/decode. It is not a first-line replacement for the one-GPU A/B test: on a single model running on a single GPU, benchmark the underlying engine first.

Run only one external server at a time and start with conservative memory caps:

# vLLM, one-GPU A/B server. Runtime LoRA updating is required because
# retrain overwrites and reloads _live_adapter after each optimizer step.
VLLM_USE_FLASHINFER_SAMPLER=0 \
VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS=0 \
VLLM_ALLOW_RUNTIME_LORA_UPDATING=True \
vllm serve Qwen/Qwen3.5-2B \
  --host 127.0.0.1 --port 8000 \
  --served-model-name Qwen/Qwen3.5-2B \
  --dtype half --max-model-len 32768 \
  --gpu-memory-utilization 0.35 \
  --enable-lora --max-lora-rank 8 --max-loras 1 --max-cpu-loras 2 \
  --max-num-seqs 4 --max-num-batched-tokens 4096

# SGLang, one-GPU A/B server. The target modules are required when the
# server starts without an initial --lora-paths adapter.
CUDA_HOME=/tmp/retrain-cuda-home-cu13 \
PATH=/path/to/sglang-env/bin:$CUDA_HOME/bin:$PATH \
python -m sglang.launch_server \
  --model-path Qwen/Qwen3.5-2B \
  --served-model-name Qwen/Qwen3.5-2B \
  --host 127.0.0.1 --port 30000 \
  --dtype half --context-length 32768 \
  --mem-fraction-static 0.35 \
  --attention-backend triton --sampling-backend pytorch --grammar-backend none \
  --disable-cuda-graph \
  --enable-lora --max-lora-rank 8 \
  --lora-target-modules q_proj k_proj v_proj o_proj gate_proj up_proj down_proj \
  --max-loras-per-batch 1

# TensorRT-LLM, one-GPU A/B server. Use a port that can coexist with vLLM,
# and make the LoRA config match the PEFT adapter target modules used by the
# retrain config. TensorRT-LLM receives the live adapter via per-request
# lora_request, so retrain does not call a reload endpoint.
cat > /tmp/retrain-trtllm-lora.yaml <<'YAML'
lora_config:
  max_lora_rank: 8
  max_loras: 1
  max_cpu_loras: 2
  lora_target_modules:
    - attn_q
    - attn_k
    - attn_v
  trtllm_modules_to_hf_modules:
    attn_q: q_proj
    attn_k: k_proj
    attn_v: v_proj
YAML

trtllm-serve Qwen/Qwen3.5-2B \
  --host 127.0.0.1 --port 31000 \
  --config /tmp/retrain-trtllm-lora.yaml

For pip-installed CUDA toolkits, SGLang's JIT linker may require a conventional $CUDA_HOME/lib64/libcudart.so linker name. If the toolkit only provides a versioned runtime such as libcudart.so.13, create a temp CUDA home with bin, include, nvvm, and lib64/libcudart.so symlinks before launching SGLang.

Then run the comparison harness against the live server:

uv run python scripts/one_gpu_backend_compare.py config.toml \
  --engines vllm \
  --vllm-url http://127.0.0.1:8000 \
  --group-size 2 --microbatch-size 1 \
  --cuda-empty-cache true --gradient-checkpointing true \
  --sample-use-cache true --prefix-caching true

Repeat with --engines sglang after stopping vLLM and starting SGLang. Keep the PyTorch baseline in the same output root or note the baseline path in the run manifest. The harness preflights /health or /v1/models and fails vLLM/SGLang runs when token-ID prompts fall back to decoded text, unless --allow-token-prompt-fallback is set. It also fails multi-step vLLM/SGLang/TensorRT-LLM runs when no adapter freshness signal succeeds, unless --allow-adapter-reload-failure is set.

For TensorRT-LLM, run the same harness after starting trtllm-serve:

uv run python scripts/one_gpu_backend_compare.py config.toml \
  --engines trtllm \
  --trtllm-url http://127.0.0.1:31000 \
  --group-size 2 --microbatch-size 1 \
  --cuda-empty-cache true --gradient-checkpointing true \
  --sample-use-cache true --prefix-caching true

LoRA weight sync

After each training step, updated LoRA weights must reach the inference engine:

Engine Sync mechanism Latency
PyTorch (1 GPU) Same model object, no sync needed 0
PyTorch (split mode) In-memory sync_from_state_dict() via snapshot ~1ms
vLLM / SGLang save_pretrained() to disk, then server reload endpoint ~1-2s
TensorRT-LLM / MLX-LM save_pretrained() to disk, then per-request adapter path server-dependent
MAX save_pretrained() to disk, then reload_weights() ~1-2s

The _weights_dirty flag avoids redundant saves. In split mode, a weight snapshot is taken after each optimizer step for safe cross-thread access. For vLLM/SGLang, retrain calls the reload endpoint every time _live_adapter is rewritten, even though the path string is stable; same path does not imply same adapter contents. vLLM uses /v1/load_lora_adapter with load_inplace; SGLang uses /load_lora_adapter and unloads the previous default LoRA name before reloading it. For TensorRT-LLM, retrain records the new _live_adapter path after each optimizer step and sends it as lora_request on each completion request.

Device allocation

Config Training Inference
engine = "pytorch", devices = "gpu:0" GPU 0 GPU 0 (same model)
engine = "pytorch", devices = "gpu:0,gpu:1" GPU 1 GPU 0 (split mode)
engine = "max", devices = "gpu:7" GPU 7 MAX-managed
engine = "vllm", devices = "gpu:7" GPU 7 Server-managed
engine = "trtllm", devices = "gpu:7" GPU 7 Server-managed
engine = "mlx", devices = "cpu" CPU MLX-LM server-managed

With external engines, devices controls only the training GPU. The engine manages its own GPU allocation independently.

TOML configuration

[inference]
engine = "pytorch"         # pytorch | max | vllm | sglang | trtllm | mlx | openai
url = ""                   # server URL for non-PyTorch engines
attention_kernel = "default"
dtype = "auto"
kv_cache_dtype = "auto"
prefix_caching = true

For the local PyTorch engine, prefix_caching = true enables bounded exact-prefix KV reuse within a rollout sampling phase. This targets multi-turn environments where the next prompt extends a previous prompt/completion with new environment observations. The cache is cleared when checkpoint() prepares a new sampling phase so stale KV from old adapter weights is not reused.

InferenceEngine ABC

All engines implement three methods:

Method Purpose
generate(prompt_ids_list, num_samples, max_tokens, temperature, top_p) Return [num_prompts][num_samples] of token IDs and logprobs
reload_weights(adapter_path) Reload a LoRA adapter from disk
shutdown() Release resources

SampleResult is a dataclass with token_ids: list[int] and logprobs: list[float].

PyTorchEngine adds sync_from_state_dict(lora_dict) for fast in-memory weight sync in split mode.

Files

File Role
retrain/inference_engine/__init__.py Exports + create_engine() factory
retrain/inference_engine/base.py InferenceEngine ABC + SampleResult dataclass
retrain/inference_engine/pytorch_engine.py Local PyTorch engine
retrain/inference_engine/max_engine.py MAX engine (in-process vs serve)
retrain/inference_engine/openai_engine.py HTTP client for vLLM / SGLang / TensorRT-LLM / MLX-LM / any server
retrain/backends/local/train.py Orchestrates local training, engine setup, and weight sync
retrain/backends/local/lora.py Builds and applies local-backend LoRA setup