Repository navigation
[Perf] Fork-safe import: no CUDA context at import time, lighter argument parsing - #40201
Conversation
|
@xenshinu the CI tests failed, please check https://github.com/sgl-project/sglang/actions/runs/35368544693 |
| return None | ||
| try: | ||
| idx = 0 | ||
| getter = getattr(torch.cuda, "_get_nvml_device_index", None) |
There was a problem hiding this comment.
_get_nvml_device_index is a private torch API that may be absent. Add a note so it is not changed to a direct attribute access.
| @dataclass | ||
| class GraphCaptureContext: | ||
| stream: torch.get_device_module().Stream | ||
| # String annotation on purpose: evaluating torch.get_device_module() here |
There was a problem hiding this comment.
trim to one or two lines, also for other comments. ref https://github.com/sgl-project/sglang/tree/main/.claude/rules
| return decode_jpeg_with_fancy_upsampling(image_bytes) | ||
| # Lazy: torchvision (and torch._dynamo behind it) costs ~1.3 s at | ||
| # import and is only needed for GPU JPEG decode. | ||
| from torchvision.io import decode_jpeg |
There was a problem hiding this comment.
add a short note on why this import is lazy, like the HfApi import.
| idx = 0 | ||
| pynvml.nvmlInit() | ||
| try: | ||
| handle = pynvml.nvmlDeviceGetHandleByIndex(idx) |
There was a problem hiding this comment.
_get_nvml_device_index missing or raising leaves idx=0, which queries physical GPU 0 and returns the wrong SM under CUDA_VISIBLE_DEVICES/MIG. Return None and fall back to torch, with a test.
There was a problem hiding this comment.
Fixed. The helper returns None when _get_nvml_device_index is missing or raises, and get_device_sm() falls back to torch.cuda.get_device_capability().
| from sglang.srt.function_call.parser_names import TOOL_CALL_PARSER_NAMES | ||
| from sglang.srt.parser.reasoning_parser_names import REASONING_PARSER_NAMES | ||
|
|
||
| reasoning_parser_choices = list(REASONING_PARSER_NAMES) |
There was a problem hiding this comment.
Static choices drop parsers a plugin registers before parsing (engine.py:1090). Is that path still supported? If so keep the dynamic choices and add a test.
There was a problem hiding this comment.
Thanks for pointing this out!
The choices now prefer the live registry whenever it is already in sys.modules and fall back to the name list otherwise.
I kept the name lists because they are worth about 535 ms of imports in every process, but I am good with dropping it if it is not worthy chasing.
73d0ef3 to
ecc68ef
Compare
| ) | ||
|
|
||
| return decode_jpeg_with_fancy_upsampling(image_bytes) | ||
| from torchvision.io import decode_jpeg # lazy: ~1 s of torch._dynamo |
There was a problem hiding this comment.
changed to a short note
|
|
||
|
|
||
| def _reasoning_parser_choices(): | ||
| # Importing the registry here costs seconds in every process that parses |
There was a problem hiding this comment.
The choices now prefer the live registry whenever it is already in sys.modules and fall back to the name list otherwise.
I kept the name lists because they are worth about 535 ms of imports in every process. Let me know if you prefer to drop it.
|
/rerun-failed-ci |
Importing sglang created a CUDA context in every process (launcher, detokenizer, DP controller): get_device_sm() and the platform probes that call it went through torch.cuda.get_device_capability(), and get_device_module() asked torch for the current accelerator. Besides the wasted init, a process with a CUDA context cannot serve as a fork() parent. - get_device_sm(): answer from NVML while torch.cuda is uninitialized, and fall back to torch when torch's private ordinal-to-NVML index mapping is unavailable, rather than reporting physical GPU 0 under CUDA_VISIBLE_DEVICES or MIG. - get_device_module(): pick the module from the platform checks. - GraphCaptureContext.stream: annotate with torch.Stream. Evaluating torch.get_device_module() in the class body marks the process unsafe to fork -- a child then dies in cuInit -- while torch.cuda.is_initialized() stays False, so nothing reports it until a worker fails. This removes sglang's own import-time initializer. sgl_kernel still creates a context when it is imported, so the launcher keeps one today; what changes is that the modules a forkserver preloads no longer do.
Importing sglang.srt.server_args pulled the parser registries (OpenAI protocol models, xgrammar, torch.distributed) and sglang.kernels (via the kv-canary enum) into every process that parses arguments, and a few utilities imported heavy optional packages at module level. - CLI choices for --reasoning-parser / --tool-call-parser come from dependency-free name lists (function_call/parser_names.py, parser/reasoning_parser_names.py), checked against the registries by unit tests. cli/serve.py loads plugins before it parses arguments, and a plugin that registers a parser has imported the registry to do so, so the live registry is preferred whenever it is already in sys.modules. - The kv-canary enum is imported when the parser is built. FunctionCallParser, ReasoningParser and RealKvHashMode stay importable from server_args through a module __getattr__, like the other re-exports above it. - cli/utils.py looks for diffusers before importing sglang.multimodal_gen, and imports HfApi where it is used. - utils/common.py imports torchvision where the GPU JPEG decode uses it: at module level it pulls torch._dynamo into every process that imports this module, and no other path here needs it. - hf_transformers_patches.py uses a plain logger instead of the one from sglang.srt.utils, which pulls torch into every `import sglang`. Behaviour is unchanged; only import order and cost move.
ecc68ef to
8560eb9
Compare
Following #39683, decompose it into a stack of PRs for better management.
WHAT IS IN THIS PR:
Motivation: importing sglang created a CUDA context in every process through device probes, and
server_argspulled the parser registries andsglang.kernelsinto every process that parses arguments. A process with a CUDA context cannot be afork()parent, which rules out forkserver-based worker startup (PR 2), and the import cost lands on the launcher's critical path (PR 3).Modifications:
get_device_sm()answers from NVML whiletorch.cudais uninitialized;get_device_module()picks the module from the platform checks;GraphCaptureContext.streambecomes a string annotation; DeepGEMM readsSGLANG_JIT_DEEPGEMM_PRECOMPILEat call time. CLI choices for--reasoning-parser/--tool-call-parsercome from dependency-free name lists asserted in sync with the registries; the kv-canary enum, torchvision andHfApiare imported where used; flashinfer's JIT logger level is set by name. Always on; behaviour unchanged. Note for reviewers:sgl_kernelstill initializes CUDA on import on the default path, so the launcher keeps a context until that is fixed upstream; PR 2 answers those probes from NVML inside the forkserver.Accuracy: no model code touched. Speed:
import sglang.srt.server_argsno longer imports the parser registries (about 3 s of imports on our hosts); end-to-end gains are measured in PR 3. Tests:function_call/test_parser_names.py,parser/test_reasoning_parser_names.py.OLD DESCRIPTION:
Motivation
A cold
sglang servespends 15-25 s in process init before any model work starts: the launcher imports the server stack, then every spawned worker re-imports the same sglang/torch stack, and on the DP path the DP controller re-imports once more before it spawns the schedulers. Every process also created a CUDA context at import time, which wastes a context in the launcher, detokenizer and DP controller and rules outfork()-based worker startup.This PR makes the import fork-safe and, behind three opt-in environment variables, lets workers fork from a preloaded forkserver and start before the launcher imports its HTTP stack. With the variables unset every code path runs exactly as before.
Time from
sglang serveto/health, Qwen3-30B-A3B (bf16) on H200, mean of three warm passes per cell (the first, cold pass that fills the weight page cache and the kernel JIT caches is excluded; one pass ran with the launch order reversed; all agree within 1 s):Where the time goes, from the rank-0 log stages:
Modifications
Four commits, each usable on its own:
get_device_sm()answers from NVML while torch.cuda is uninitialized,get_device_module()picks the module from the platform checks,GraphCaptureContext.streambecomes a string annotation, DeepGEMM readsSGLANG_JIT_DEEPGEMM_PRECOMPILEat call time. A process without a CUDA context can be afork()parent.SGLANG_EARLY_FORKSERVER=1, newentrypoints/early_forkserver.py). The multiprocessing forkserver starts at CLI entry with the scheduler, DP controller and detokenizer modules preloaded; workers fork from it instead of re-importing. The child re-applies the launcher's environment and stdio, re-derives the HF offline flags and reports the launcher as its parent.python -m sglang.srt.entrypoints.early_forkserverruns a long-lived daemon that publishes its address inSGLANG_FORKSERVER_FILE.SGLANG_PRESPAWN_WORKERS=1, newentrypoints/prespawn.py,entrypoints/worker_launch.py,managers/process_entry.py).run_server()resolves the config and spawns the schedulers (or the DP controller) right after argument parsing, before importing the HTTP and tokenizer stack;Engine._launch_subprocessesadopts them. Weight-cache daemons, the engine-info bootstrap server and Ray stay on the normal path.sglang/__init__.py,server_args.py,cli/utils.py,utils/common.py). Under the pre-spawn flag the package applies the transformers patches when transformers is imported and resolves the frontend API lazily (import sglang: 4.6 s -> 0.24 s); by default it imports eagerly as before. CLI choices for the parsers come from dependency-free name lists asserted in sync with the registries; a few imports move to their call sites.New environment variables (documented in
environment_variables.mdx):SGLANG_EARLY_FORKSERVER(default off),SGLANG_FORKSERVER_FILE(default/tmp/sglang_forkserver.json),SGLANG_PRESPAWN_WORKERS(default off),SGLANG_MP_START_METHOD(set bystart_early(), not meant to be set by hand).Accuracy Tests
No change to model forward or sampling code. Greedy output for "The capital of France is" is identical across all 48 launches of the matrix above and, in an earlier run, identical to upstream main in the same environment.
Speed Tests and Profiling
See the tables and figure in Motivation (4 configurations x 3 settings x 3 warm passes on one H200 host;
experimental/startup/startup_matrix.shandstartup_matrix_report.pyin the foundry-org repo). Also on the branch:import sglang4.6 s -> 0.24 s under the pre-spawn flag,import sglang.srt.server_args5.3 s -> 3.0 s. With the knobs on, pre-spawn fires about 6.5 s after interpreter start: 1.7 s of imports and 4.5-5 s ofServerArgs.resolve_once(), which this PR keeps ahead of the spawn so the workers receive the same record as on the normal path.Unit tests (CPU,
base-a-test-cpu):entrypoints/test_early_forkserver.py,entrypoints/test_prespawn.py,entrypoints/test_worker_launch.py(copy matches the engine source-for-source; light modules never import the server stack),cli/test_sglang_init.py(eager default; lazy mode stays free of torch/transformers),function_call/test_parser_names.py,parser/test_reasoning_parser_names.py.How to reproduce
Unit tests (about 15 s):
cd test/registered/unit python3 -m unittest entrypoints.test_early_forkserver entrypoints.test_prespawn entrypoints.test_worker_launch cli.test_sglang_init function_call.test_parser_names parser.test_reasoning_parser_namesLauncher import cost with and without the lazy package init:
Time to
/healthfor one configuration under the three settings (setMODEL,ARGSsuch as--dp 2or--tp 2, and a freePORT):Run each setting once to warm the page cache and kernel caches, then measure. The stage breakdown comes from the rank-0 log timestamps in
server.logrelative tot0:Init torch distributed begin(end of process init),Load weight begin/end,KV Cache is allocated, firstCapture ... CUDA graph beginto last... end, firstGET /health ... 200.Follow-up work
ServerArgs.resolve_once()(4.5-5 s), which this PR keeps ahead of the spawn so workers get the same record as the normal path. Two options keep a single resolution and still move the fork to about 2 s: make resolution cheaper (most of it is imports of the model stack, recoverable with lazy imports), or spawn the workers first and have the child block on the launcher's resolved record over the pipe before it does anything. With either, the daemon's instant fork becomes the critical-path win it was designed to be.get_tokenizer()in the scheduler process (the tokenizer is loaded twice, about 0.6 s), lazy LoRA drainer/overlap-loader imports in the scheduler (about 0.5 s).worker_launch.py, if the reviewers prefer that over the guarded copy.Checklist
CI States
Latest PR Test (Base): ✅ Run #35521688622
Latest PR Test (Extra): ✅ Run #35521688477
Latest PR Test (AMD ROCm 10): ❌ Run #35521688646