pulse model starts and stops a measured vLLM profile on a DGX Spark node. One
command writes the recipe .env, stops the old server, waits for memory,
starts the recipe, waits until /v1/models returns 200, and checks the result.
It also writes a backend file for the Pulse gateway.
The recipe is the single-Spark Qwen3.8-Flash-Next recipe (start.sh,
stop.sh, .env). runtime/nodes.json gives its directory on each node.
Pulse does not change the recipe. It writes only the recipe .env, a backup of
it, and a log file in logs/.
The command is TypeScript. Compile it before use:
npx tsc
node bin/pulse-cli model --helpDo not use npm run build for this. That script also runs make for the CUDA code.
| Command | What it does |
|---|---|
pulse model profiles |
Lists the profiles and the nodes. |
pulse model up <profile> --node <node> |
Brings the profile up on the node. |
pulse model up ... --dry-run |
Prints the rendered .env and the script of each step. It runs nothing. |
pulse model up ... --dry-run --preflight |
Also runs the read-only checks on the node. |
pulse model down --node <node> |
Stops the server on the node. |
pulse model status [--node <node>] |
Shows the container, the profile, drift, health, the KV cache size and memory. |
pulse model smoke --node <node> |
Sends one short chat completion on the node. A protected node needs --yes. |
pulse model fragment |
Writes the gateway backend file again from the saved state. |
Options for up:
--overlay DIRadds the pairs inDIR/docker-args.txttoEXTRA_DOCKER_ARGS.--overlay-winslets an overlay mount replace a profile mount on the same path.--yesallows a change on a protected node. spark1 is protected, because it serves live Codex traffic.--experimentalallows a profile withpulse-status: experimental.--restartrestarts the server also when the node already runs the same.env.--timeout-s Nsets the readiness timeout. The default isREADY_TIMEOUT_S(1800) plus 300.--min-mem-gib Nsets the memory gate for this run.--verboseshows the raw@pulserecords of the node scripts.
Options for status: --json, --no-http (no request to the server), and
--gpu-probe. The GPU probe starts a GPU container on the node, so it runs
only when you ask for it.
A protected node gets these limits, also when status has no --node:
statussends no HTTP request to the server without--yes. It still shows the container, the.env, the KV cache line and memory.status --gpu-proberefuses without--yes. Name the node with--nodeto probe only another node.smokerefuses without--yes.
This file holds real hosts, IPs and paths for one site, so it is gitignored.
Copy runtime/nodes.example.json to runtime/nodes.json and edit it for your
nodes.
One entry for each node:
| Field | Meaning |
|---|---|
host |
local (this host) or ssh. |
ssh |
The ssh target, for example spark2. |
protected |
true makes up, down, smoke and status --gpu-probe refuse without --yes, and status sends no HTTP request without --yes. |
env |
The node keys of the .env: HF_HOME, BIND, PORT, REQUIRE_IDLE_GPU. A profile cannot set them. |
vars |
Values for {{name}} in profiles, for example tritonCache and mtpShardR2. |
gatewayUrl |
The URL that the gateway uses for this node. |
gpuProbe |
The command for status --gpu-probe. |
minMemAvailableGiB |
Optional. It replaces the top-level memory gate for this node. |
recipes.<name> |
dir, start, stop, container, optional dockerArgs and vars. |
The top level also sets minMemAvailableGiB (100), memTimeoutS (600) and
readyGraceS (300). The variable {{recipeDir}} is always the dir of the recipe.
A profile is a complete key set for the recipe .env. It is not a list of
changes. The reason: start.sh has built-in defaults that are different from
.env.sample, so a key that is not in the file silently changes the server.
The format is a small, safe part of bash:
# pulse-recipe: qwen38-flash
# pulse-status: stable
# pulse-description: one line for `pulse model profiles`
# pulse-mkdir: {{tritonCache}}
# pulse-proof: MTP draft head: FP8 rowwise copy engaged
MAX_NUM_SEQS=4
CHAT_TEMPLATE="files/chat-template/chat_template.jinja"
EXTRA_DOCKER_ARGS="
-e VLLM_USE_V2_MODEL_RUNNER=1
-v {{tritonCache}}:/triton-cache
"Rules:
- A value in double quotes must not contain
$, a backtick, a backslash or a double quote. - A value without quotes can contain only letters, digits and
_ . / : = , @ + % -. - Only
EXTRA_DOCKER_ARGSandEXTRA_VLLM_ARGScan use more than one line. EXTRA_DOCKER_ARGScan contain only-e NAME=VALUEand-v SRC:DST[:ro|rw]pairs. The reason:start.shputs this value without quotes into a generated bash script.- A key can occur only one time.
HF_TOKEN,API_KEYandTP1_CONTAINER_NAMEare not allowed. --api-keyis not allowed inEXTRA_VLLM_ARGS. Put the key in the node.envasAPI_KEY.
Directives:
| Directive | Meaning |
|---|---|
pulse-recipe |
Required. The recipe in recipes of the node. |
pulse-status |
stable or experimental. |
pulse-runnable |
false makes the profile render-only. |
pulse-mkdir |
A directory that up creates on the node before the start. |
pulse-proof |
A text that must occur in the container log. A missing text gives a warning. |
pulse-client |
A note for the client, which up prints. |
The shipped profiles:
| Profile | Use |
|---|---|
best |
The best single-Spark profile: MTP K=6, FP8 draft head, block-drop backport, MTP index share, the r2 MTP shard, 524288 context. K=6 needs the recipe at spark-tuning c840e30 or later. |
datagen |
Data generation: 8 sequences, 262144 context, the stock MTP head. |
capture |
MTP hidden-state capture. Each request needs a unique cache_salt and X-Request-Id: cap-<row>. |
tp2 |
Draft only. Two nodes with tensor parallel size 2. It is render-only (see below). |
best, datagen and capture set MEMWATCH_RELIEF=drop_caches. Before the
MemFree floor of the recipe watchdog stops the container, the watchdog drops the
clean page cache one time with sudo -n and samples again. The recipe added
this setting on 2026-09-23, after the floor stopped spark1 with 5.7 GiB of page
cache resident. The relief needs sudo -n on the node. If sudo -n fails, the
watchdog logs the failure one time and then works as with the relief off.
Each value is written as KEY="value". A header comes first:
# pulse-profile: best
# pulse-node: spark2
# pulse-recipe: qwen38-flash
# pulse-overlay: none
# pulse-rendered: 2026-09-23T10:15:00Z
# pulse-sha256: <sha256 of the assignment lines>
The sha256 covers only the assignment lines. status uses it to find a file
that somebody edited after the render. With an overlay, the pulse-overlay
line holds the overlay directory and overlay-sha256, the sha256 of
docker-args.txt and manifest.json.
The identity of a .env is the body sha256, the profile and the
pulse-overlay line. up writes no new file when all three are the same.
If the old .env has HF_TOKEN= or API_KEY= lines, the node script copies
them to the end of the new file. Pulse never prints these values.
start.sh gives API_KEY to vLLM as --api-key. Then vLLM refuses requests
to /v1 without the key. So each curl of up, status and smoke reads
API_KEY from the node .env and sends it as a bearer token. The key goes to
curl on stdin, so it is not on a command line.
- Preflight (read-only). The node script checks the recipe, the image, every
mount source, the files in the HF cache under each mount target,
CHAT_TEMPLATE,MTP_DRAFT_VOCAB, and that no other vLLM container runs. It reads the current.envin an empty environment and prints the difference between the current key map and the render. It also counts the open client connections. A failure stopsupbefore any change. - It creates the
pulse-mkdirdirectories. - It runs
stop.shand makes sure that the container is gone.stop.shreads the.env, so the old.envstays in place until this step is done. - It waits until
MemAvailableis at the gate (100 GiB).stop.shreturns before the memory is free. - It writes the
.env. First it copies the old file to.env.pulse-bak-<UTC time>. It does not write the file when the identity (the sha256, the profile and the overlay) is the same. - It starts
start.shin a new session withsetsid, so a lost ssh connection cannot stop it.env -iremoves the caller environment, so the.envalone sets the recipe keys. The log goes tologs/pulse-up-<UTC time>.log. - It waits until
/v1/modelsreturns 200 on the node. - It checks the served model name,
max_model_len, the KV cache line and thepulse-prooftexts. - It writes the node state and the gateway backend file.
If a node already runs a .env with the same identity, up does only steps
1, 7, 8 and 9. This is true only when the container started after the last
change of the .env and after the last change (ctime) of each file that it
mounts. A bind mount of a file keeps the old inode, so a rebuilt overlay file
or a new MTP shard needs a restart, and up does it. Use --restart to
restart the server in all cases.
If a step fails after the stop step started, up marks the node failed in
its state, and the gateway backend file disables its endpoint. up prints the
backup path if it wrote one, and the command that goes back to the previous
profile. If down fails in its stop step, it also marks the node failed.
--dry-runruns no command on any node and writes no file. It prints the rendered.envand the full script of each step.--dry-run --preflightruns only step 1, which only reads.- Every node script goes to
bash -son stdin. The login shell of the node does not parse it. On spark2 the login shell is zsh. upanddownrefuse a protected node without--yes.smokeandstatus --gpu-probealso refuse it, andstatussends it no HTTP request.uprefuses a render-only profile, and an experimental profile without--experimental.- Pulse never calls
start.sh --no-launch. That mode starts helper containers and writes.last_launch.sh. - Pulse does not copy files between nodes. A missing mount source stops the preflight.
Pulse keeps its state in $PULSE_STATE_DIR. The default is
$XDG_STATE_HOME/pulse/runtime or ~/.local/state/pulse/runtime:
nodes/<node>.json: the lastupordownon the node.gateway-backends.json: a partial gateway config with oneqwen38model and one endpoint for each node.
An endpoint is enabled when the gateway can reach the node and the node state
is not down, failed or starting. The gateway rejects a model with no
enabled endpoint, so the first node stays enabled in that case, with a warning.
The gateway reads its config only when it starts. To use the file:
- Set
PULSE_GATEWAY_CONFIGto the file, or start the gateway with--config <file>. - Make sure that
PULSE_QWEN_BACKENDSis not set. That variable replaces the endpoints of the file. If you run thepulse-qwen38user service fromdeploy/systemd, remove the variable from~/.config/pulse/qwen38.env. - Restart the gateway. For the user service:
systemctl --user restart pulse-qwen38.
Pulse does not do these steps. The file holds only models, so the other
gateway settings get their default values.
The server on spark2 listens on 127.0.0.1 (BIND in runtime/nodes.json).
The gateway on spark1 cannot reach it, so the spark2 endpoint stays disabled.
Do not change BIND without a decision:
BIND=203.0.113.2breaks the readiness check ofstart.sh, which useslocalhost.start.shthen removes the container after the timeout.BIND=0.0.0.0makes the server open on every interface of spark2, also the wireless LAN and each VPN interface. The server has no key, and the gateway cannot send one.
Possible solutions: an ssh tunnel from spark1, a change to start.sh so that
it checks $BIND, or 0.0.0.0 with a firewall.
overlays/qwen38/build.sh --out DIR --set NAMES writes DIR/docker-args.txt
and DIR/manifest.json. docker-args.txt holds -e and -v pairs with white
space between the words. The builder writes all pairs on one line. Pulse also
reads pairs on more than one line, and lines that start with # are comments.
The paths must be absolute paths on the node. Pulse writes manifest.json
into the node state without a change.
A new build with a different docker-args.txt or manifest.json changes the
overlay-sha256 of the .env, so up writes a new .env and restarts the
server. The builder replaces a changed file with a new inode. A running
container does not see the new file, and the mount check of up restarts the
server for this case too.
An overlay mount on the same path as a profile mount is an error. Use
--overlay-wins to replace the profile mount; the plan then shows each
replacement. A mount on a path that start.sh mounts itself (for example
mtp.py) is always an error.
start.sh of the single-Spark recipe always uses tensor parallel size 1.
Tensor parallel size 2 needs the two-node recipe (qwen38-dual in runtime/nodes.json).
spark1 is its head node, and its stop.sh stops the servers on both nodes. So
tp2 is render-only: up tp2 --dry-run shows the .env, and a real up refuses.
- The memory gate is 100 GiB, and it depends on
vm.watermark_scale_factor. A higher factor makesMemAvailablesmaller for the same free memory. With factor 300 (a test on 2026-09-23),MemAvailablewith no server was 96.6 GiB on spark1 and 99.4 GiB on spark2, so the memory wait (step 4) would time out. The same change also stopped the spark2 server:MemAvailablefell below the 6 GiB floor of the recipe watchdog.statusshows the factor. If you keep a high factor, set a new gate withminMemAvailableGiBinruntime/nodes.jsonor with--min-mem-gib. nvidia-smion spark2 did not always list the vLLM container.statusshows the list for information only. The container state and memory are the real signals.- Docker rotates the container log at 50 MB, three files. When the KV cache
line is gone from the log,
statusshows the value thatupsaved.