|
| 1 | +# Build, rebuild, and test at each scale |
| 2 | + |
| 3 | +A companion to [TROUBLESHOOTING.md](TROUBLESHOOTING.md). That file is keyed on |
| 4 | +symptoms — *something broke, what is it*. This one is keyed on tasks — *I need |
| 5 | +to build / rebuild / run something at N GPUs, how*. |
| 6 | + |
| 7 | +Most of it is not this workflow's knowledge. It is collected from the places |
| 8 | +in TensorRT-LLM that already do these things correctly, each named so you can |
| 9 | +read the current version rather than trust this page: |
| 10 | + |
| 11 | +| where | what it owns | |
| 12 | +|---|---| |
| 13 | +| `scripts/build_wheel.py` | the build itself, and every flag below | |
| 14 | +| `.claude/skills/exec-slurm-compile/` | building on a Slurm cluster, and `enroot import` | |
| 15 | +| `.claude/skills/trtllm-test-script-builder/` | generating Slurm test scripts — **the launch rules** | |
| 16 | +| `.claude/skills/exec-local-slurm/` | persistent allocations, container reuse, step cancellation | |
| 17 | +| `tests/integration/test_lists/test-db/*.yml` | which tests run at which GPU count | |
| 18 | +| `scripts/test_to_stage_mapping.py` | which CI stage a given test lands in | |
| 19 | + |
| 20 | +--- |
| 21 | + |
| 22 | +## 0. First ask: do you need to build at all? |
| 23 | + |
| 24 | +**For a modeling_v2 onboard, almost certainly not.** This is worth checking |
| 25 | +before spending an hour, because the answer is usually no: |
| 26 | + |
| 27 | +| what you changed | rebuild? | |
| 28 | +|---|---| |
| 29 | +| a target's `modeling.py` / `weights.py` | **no** — pure Python, editable install picks it up | |
| 30 | +| a catalog entry `.py`, shared or in a target's `ops/` | **no** | |
| 31 | +| a FlashInfer call (sourcing tier 3) | **no** — the package is installed, its kernels JIT | |
| 32 | +| a Triton kernel (sourcing tier 5) | **no** — Triton is JIT | |
| 33 | +| routing, `_router_index.py` | **no** | |
| 34 | +| anything under `cpp/` | yes | |
| 35 | +| a new `torch.ops.trtllm.*` op | yes | |
| 36 | +| pulling a trunk that touched `cpp/` | yes | |
| 37 | + |
| 38 | +The five sourcing tiers this workflow uses — an exported op, the kernel plane, |
| 39 | +FlashInfer's public API, native torch, Triton — **none of them require a native |
| 40 | +rebuild**. That is why |
| 41 | +the orchestrator never builds anything and `environment.python` just points at |
| 42 | +an install someone else made. |
| 43 | + |
| 44 | +If you are rebuilding during an onboard, stop and confirm why. |
| 45 | + |
| 46 | +### Rebuilding while compute sessions are running |
| 47 | + |
| 48 | +`build_wheel.py` installs `tensorrt_llm/libs/*.so` by rewriting each file in |
| 49 | +place. Any process, on any node, that has the old file mapped then faults on |
| 50 | +its next page-in, and when that process is attached to its allocation's MPI it |
| 51 | +takes the whole allocation with it. Measured: a rebuild on one session killed |
| 52 | +another session thirty seconds later. |
| 53 | + |
| 54 | +So rebuild through the workflow's `incremental_build.sh` (in the package's |
| 55 | +`scripts/` directory), as a whole-node spool job: |
| 56 | + |
| 57 | +```bash |
| 58 | +spoolctl.py submit --gpus all --wall 3h build-<what> \ |
| 59 | + 'bash <package>/scripts/incremental_build.sh --repo <checkout> --spool <spool A> --spool <spool B>' |
| 60 | +``` |
| 61 | + |
| 62 | +It takes a per-checkout build lock, announces the rebuild on every listed |
| 63 | +spool (each session's scheduler then holds new GPU jobs against that checkout), |
| 64 | +gives every installed library a fresh inode by copy-and-rename so running |
| 65 | +processes keep the file they mapped, runs the build with ccache, checks the |
| 66 | +checkout still imports, and retracts the announcement — also when the build |
| 67 | +fails. Spools default to `$MV2FLOW_SESSION_SPOOLS`, which every rendered |
| 68 | +session exports. |
| 69 | + |
| 70 | +## 1. Build |
| 71 | + |
| 72 | +```bash |
| 73 | +python3 ./scripts/build_wheel.py \ |
| 74 | + --use_ccache \ |
| 75 | + --skip_building_wheel \ |
| 76 | + --cuda_architectures "100-real;103-real" |
| 77 | +``` |
| 78 | + |
| 79 | +| flag | when | |
| 80 | +|---|---| |
| 81 | +| `--cuda_architectures` | **always set it.** `100-real` Blackwell B200/GB200, `103-real` GB300, `90-real` Hopper, `89-real` L40S, `80-real` A100. Building all of them is the slowest thing you can do | |
| 82 | +| `--use_ccache` | always. A no-op change rebuilds in minutes instead of an hour | |
| 83 | +| `--skip_building_wheel` | development — builds in place, no wheel artifact | |
| 84 | +| `--fast_build` / `-f` | skips some kernels. Development only — **do not gate on a fast build** | |
| 85 | +| `--clean` / `-c` | wipes the build directory. The answer to "weird compile issues" | |
| 86 | +| `--configure_cmake` | force a CMake re-configure. Needed when a CMake input changed but the build did not notice | |
| 87 | +| `-j <N>` | lower it when the build OOMs | |
| 88 | + |
| 89 | +`build_wheel.py` creates its own venv and runs `pip install -e .[devel]` into |
| 90 | +it, so the interpreter it produces is the one to put in `environment.python`. |
| 91 | + |
| 92 | +### ccache does not carry across checkouts |
| 93 | + |
| 94 | +Measured: pointing `CCACHE_DIR` at another checkout's cache produced no |
| 95 | +useful hit rate. Absolute paths are baked into the cache keys. Give each |
| 96 | +checkout its own, and expect the first build there to be cold. |
| 97 | + |
| 98 | +### The one that wastes an afternoon |
| 99 | + |
| 100 | +A rebuild is *not* the fix for `NVRTC ... could not open source file "cuda.h"`. |
| 101 | +Measured: rebuilding with `--nvrtc_dynamic_linking` (98% ccache hits) did not |
| 102 | +fix it; the cause was the venv's `bin` missing from `PATH`. See |
| 103 | +TROUBLESHOOTING.md before you rebuild for that symptom. |
| 104 | + |
| 105 | +## 2. Build on Slurm |
| 106 | + |
| 107 | +`.claude/skills/exec-slurm-compile/` owns this end to end — `submit_compile.sh` |
| 108 | +submits, `compile.slurm` allocates, `compile.sh` runs the build inside the |
| 109 | +container, `enroot-import` fetches the image. |
| 110 | + |
| 111 | +Two failure modes from its own troubleshooting table that are not obvious: |
| 112 | + |
| 113 | +- `enroot import` **auth** failure → `~/.config/enroot/.credentials`; |
| 114 | +- `enroot import` producing an **empty or corrupt** `.sqsh` → the image URL |
| 115 | + probably has an `https://` prefix it should not have. |
| 116 | + |
| 117 | +### The environment hazard on a shared cluster |
| 118 | + |
| 119 | +`/root/.local` and `/root/.cache` may be symlinked into a shared home, so |
| 120 | +another checkout's develop-install is on the path of every container run. |
| 121 | + |
| 122 | +- **At runtime: `PYTHONNOUSERSITE=1`.** Without it an import can resolve to a |
| 123 | + different tree than the one you are measuring. |
| 124 | +- **During the native build: leave the user site enabled.** The CUTLASS kernel |
| 125 | + CMake runs `setup_library.py develop --user` and then imports |
| 126 | + `cutlass_library`. Isolate the `pip install -e` step and everything after |
| 127 | + it, not the compile itself. |
| 128 | + |
| 129 | +## 3. Test — the scale ladder |
| 130 | + |
| 131 | +Scale is declared by `system_gpu_count` in each list's `condition` block, and |
| 132 | +the file name follows it: |
| 133 | + |
| 134 | +| scale | list | `system_gpu_count` | what belongs here | |
| 135 | +|---|---|---|---| |
| 136 | +| **CPU** | `l0_cpu.yml` | `0–0` | anything that needs no device. `backend: generic` | |
| 137 | +| **1–4 GPU** | `l0_gb300.yml`, `l0_b200.yml`, `l0_h100.yml`, … | `1–4` | the ordinary pre-merge matrix | |
| 138 | +| **multi-GPU** | `l0_*_multi_gpus.yml` | `4–4`, `8–8` | collectives, TP/EP topologies | |
| 139 | +| **multi-node** | `l0_*_multi_nodes*.yml` | `8–8`+ | anything spanning nodes | |
| 140 | + |
| 141 | +To find where a test you care about runs: |
| 142 | + |
| 143 | +```bash |
| 144 | +python scripts/test_to_stage_mapping.py --tests "test_modeling_v2_allgather" |
| 145 | +``` |
| 146 | + |
| 147 | +### CPU |
| 148 | + |
| 149 | +`agent-flow`'s own suite is the clearest example: pure CPU, its own stage |
| 150 | +(`CPU-AgentFlow-UnitTest`), and `AgentFlowRule` narrows CI so an |
| 151 | +agent-flow-only change runs exactly that stage and no GPU stage at all. |
| 152 | + |
| 153 | +For the engine, CPU tests are the schema / list / prose audits — including the |
| 154 | +modeling_v2 claims audits, which are text scans and need no device. |
| 155 | + |
| 156 | +### Single GPU |
| 157 | + |
| 158 | +```bash |
| 159 | +pytest tests/unittest/_torch/modeling_v2/norm/test_modeling_v2_flashinfer_rmsnorm.py |
| 160 | +LLM_MODELS_ROOT=/path/to/models pytest tests/integration/defs/accuracy/test_modeling_v2_gpt_oss.py |
| 161 | +``` |
| 162 | + |
| 163 | +Unit tests need no models. Integration tests need `LLM_MODELS_ROOT` **and** a |
| 164 | +GPU; forgetting the variable produces a failure that reads like a missing |
| 165 | +model rather than a missing setting. |
| 166 | + |
| 167 | +### Multi-GPU: two mechanisms, and they are not interchangeable |
| 168 | + |
| 169 | +**`mpi_pool_executor`** — a fixture, worker count by indirect parametrization: |
| 170 | + |
| 171 | +```python |
| 172 | +@pytest.mark.parametrize("mpi_pool_executor", [2], indirect=True) |
| 173 | +def test_something(mpi_pool_executor): ... |
| 174 | +``` |
| 175 | + |
| 176 | +**A launcher in a fresh interpreter** — what the modeling_v2 collective |
| 177 | +entries use, and the reason is stated in |
| 178 | +`tests/unittest/_torch/modeling_v2/comm/_rank_job.py`: |
| 179 | + |
| 180 | +> A collective that breaks \*hangs\* rather than raising, so the |
| 181 | +> deadline is load-bearing and the ``mpi_pool_executor`` fixture has none. |
| 182 | +
|
| 183 | +Two consequences worth carrying into any new multi-rank test: |
| 184 | + |
| 185 | +- **A hang is the normal failure.** Whatever you write needs a deadline that |
| 186 | + kills the process group, or one wedged rank takes the whole run with it. |
| 187 | +- **You cannot start an MPI launcher from a pytest process that has already |
| 188 | + imported `tensorrt_llm`** — that process has initialized MPI. The launcher |
| 189 | + must go in a fresh interpreter, started *by file path*. |
| 190 | + |
| 191 | +### Multi-node |
| 192 | + |
| 193 | +The launch rules are the whole content here, and they come from |
| 194 | +`.claude/skills/trtllm-test-script-builder/`. **The rule is a function of the |
| 195 | +task count and nothing else:** |
| 196 | + |
| 197 | +| | `ntasks == 1` | `ntasks > 1` | |
| 198 | +|---|---|---| |
| 199 | +| `--mpi=` | `none` | `pmix` (or the cluster's variant — ask `srun --mpi=list`) | |
| 200 | +| payload | clear the MPI environment, then exec | wrap in `trtllm-llmapi-launch` | |
| 201 | + |
| 202 | +```bash |
| 203 | +srun -N 2 --ntasks 8 --ntasks-per-node 4 --mpi=pmix --gres=gpu:4 \ |
| 204 | + --container-image=... --container-name=trtllm-run \ |
| 205 | + bash -c "trtllm-llmapi-launch python3 -m tensorrt_llm.commands.serve $MODEL ..." |
| 206 | +``` |
| 207 | + |
| 208 | +Four things that are each a whole afternoon if you get them wrong: |
| 209 | + |
| 210 | +1. **`--ntasks` must match the world size.** Without it `srun` launches one |
| 211 | + task, the MPI world size is 1, and the LLM API concludes it must create the |
| 212 | + other ranks itself. `need_spawn_mpi_workers` in `llmapi/mpi_session.py` is |
| 213 | + exactly `get_mpi_world_size() == 1 and model_world_size > 1`. Under pyxis |
| 214 | + that dynamic spawn is forbidden → `MPI_ERR_SPAWN`. |
| 215 | +2. **`--gres` is per node**, not per world. They diverge the moment you span |
| 216 | + nodes. |
| 217 | +3. **At one task, clear the MPI environment.** mpi4py auto-initializes MPI |
| 218 | + whenever PMI variables are present, and inside a PMIx-managed step they |
| 219 | + always are — so a single-task job initializes MPI for no reason and then |
| 220 | + dies spawning a worker. Keep `SLURM_PROCID` and `SLURM_LOCALID`; they |
| 221 | + identify the rank rather than bootstrap MPI. |
| 222 | +4. **`TLLM_WORKER_USE_SINGLE_PROCESS=1` is not the general fix.** The function |
| 223 | + behind it is named `enable_worker_single_process_for_tp1` and the source |
| 224 | + calls it a WAR for TP1. It does not scale past one rank. |
| 225 | + |
| 226 | +Inside a multi-node compute session there is no `srun` and no sshd in the |
| 227 | +container. A session declared with `nodes: 2` runs one scheduler per node, and |
| 228 | +the workflow's `mn_mpirun.sh` (in the package's scripts directory, under |
| 229 | +`mn/`, with its own README) starts one `mpirun` across them, launching each remote daemon through |
| 230 | +that node's scheduler. |
| 231 | + |
| 232 | +Inside a spool session the workflow emits the `mpirun` form of the same rule |
| 233 | +from `environment.py` (`mpirun -n <world> ... trtllm-llmapi-launch`); nothing |
| 234 | +here is an agent's decision. The `srun` form is written out for a human |
| 235 | +launching a test by hand, who needs to recognize a correct line. |
| 236 | + |
| 237 | +## 4. Iterating without paying full price |
| 238 | + |
| 239 | +| cost | how to stop paying it | |
| 240 | +|---|---| |
| 241 | +| container import, 26 GB / 4–8 min **per job** | `--container-name=<name>` — pyxis skips the import when that name already exists on the node | |
| 242 | +| queue wait per command | a persistent allocation: `salloc` once, then `srun --jobid=<id>` steps into it (see `exec-local-slurm`) | |
| 243 | +| both, plus a transport that cannot reach Slurm at all | a compute session — one long-lived job whose **own main process** is the spool scheduler; work is a file | |
| 244 | +| cold native build | `--use_ccache`, and one cache per checkout | |
| 245 | + |
| 246 | +On the spool: work started inside a `slurm_exec_in_job` step does **not** |
| 247 | +survive the step's teardown, `nohup` included — measured. The long-running |
| 248 | +process has to be the job's own main process. If your cluster's Slurm CLI is |
| 249 | +reachable, prefer the persistent-allocation pattern; the spool exists for the |
| 250 | +case where it is not. |
| 251 | + |
| 252 | +A session is declared in `task.yaml` (`environment.sessions`) and its entry |
| 253 | +script is rendered, never written by hand: |
| 254 | + |
| 255 | +```bash |
| 256 | +trtllm-modeling-v2 session render --task task.yaml --name a # <spool>/session_entry.sh |
| 257 | +trtllm-modeling-v2 session submission --task task.yaml --name a # the broker call that starts it |
| 258 | +trtllm-modeling-v2 session status --task task.yaml # heartbeat, end, queue |
| 259 | +``` |
| 260 | + |
| 261 | +Inside a session, size each job instead of picking devices: |
| 262 | + |
| 263 | +| you run | submit it as | |
| 264 | +|---|---| |
| 265 | +| a single-GPU unit test or microbenchmark | `--gpus 1` | |
| 266 | +| a 4-rank collective test | `--gpus 4` | |
| 267 | +| anything that loads the full model, a gate, a rebuild | `--gpus all` | |
| 268 | +| a worktree's code | add `--tree <worktree>` — sets the working directory **and** `PYTHONPATH`, and refuses a result whose ranks imported another checkout | |
| 269 | +| the orchestrator, or anything GPU-free and long-lived | a `free-` name, no `--gpus` | |
| 270 | + |
| 271 | +`--wall` counts from the job's start, so time queued behind other work is |
| 272 | +free. `spoolctl.py status` says why each waiting job waits; `spoolctl.py cancel |
| 273 | +<name>` is the only way to stop one (see the safe-kill rule in |
| 274 | +TROUBLESHOOTING.md). |
| 275 | + |
| 276 | +To abort one run without losing the allocation: |
| 277 | + |
| 278 | +```bash |
| 279 | +squeue -s -j <job_id> # list steps |
| 280 | +scancel <job_id>.0 # cancel one step |
| 281 | +``` |
| 282 | + |
| 283 | +## 5. Where a new test belongs |
| 284 | + |
| 285 | +1. **No device?** → a CPU list, and check whether a CBTS rule already narrows |
| 286 | + CI for your subtree (`jenkins/scripts/cbts/rules/`). |
| 287 | +2. **One device?** → the ordinary `l0_<gpu>.yml` for the architecture you |
| 288 | + certified on. Certification is per-architecture (`ARCHS`); a pass elsewhere |
| 289 | + is not a pass here. |
| 290 | +3. **Collectives or a TP/EP topology?** → `l0_*_multi_gpus.yml`, and give it a |
| 291 | + deadline. |
| 292 | +4. **Spans nodes?** → `l0_*_multi_nodes*.yml`, and it needs the launch rules |
| 293 | + in §3. |
| 294 | + |
| 295 | +Then confirm with `scripts/test_to_stage_mapping.py` that it actually lands in |
| 296 | +a stage. A test on no list is a test nobody runs. |
| 297 | + |
| 298 | +**One trap for documentation-shaped changes:** `ModelingV2Rule` deliberately |
| 299 | +excludes `*.md` so a documentation-only edit does not pull in multi-GPU GB300 |
| 300 | +stages. That is the right trade, but it means a check that *scans* `.md` files |
| 301 | +gets no CI trigger from the PRs most likely to break it. Put text audits in a |
| 302 | +pre-commit hook, not in a GPU test stage. |
0 commit comments