From-scratch C++23/CUDA LLM inference engine targeting exactly one chip: NVIDIA Blackwell sm_120a (RTX 5090 / GB202, 32 GB GDDR7, 1792 GB/s, native FP4 tensor cores). No portability layer, no FP16 dequant fallback in the hot path. ~100k LOC (src/ + include/). See docs/architecture.md (canonical narrative) and docs/sm120.md.
Match the task, invoke that skill first; the skills below are imp-specific and carry the detailed playbooks.
| Task | Start here |
|---|---|
| Build / run tests / CI red / dep bump | skill building-and-testing |
| Write/optimize a CUDA kernel (sm_120a) | skill sm120-cuda-expert |
| Benchmark, profile, refresh perf baseline | skill benchmark-cuda |
| Verify output coherence after hot-path change | skill check-degeneration |
| Quant formats / loaders / dequant (GGUF, NVFP4, FP8) | skill quant-formats |
| imp-server / OpenAI+Anthropic HTTP API | skill server-api |
| Add a new model architecture | skill add-model-arch |
| Open/merge a PR, cut a release | skill shipping-prs |
| Structure audit / dead code / god-files | skill codebase-audit |
| Keep docs in sync after a change | skill docs-sync |
| VRAM / ownership / lifetime / "where did the memory go" | read docs/MEMORY_ARCHITECTURE.md first |
Canonical references: docs/architecture.md (narrative), docs/sm120.md (hardware), docs/MEMORY_ARCHITECTURE.md (memory subsystem: tiers, allocators, invariants I1-I7), AGENTS.md (subagent roles + guardrails), docs/BENCHMARKING.md (measurement contract).
- A successful
cudaMallocproves nothing about free VRAM on this box. WDDM oversubscribes into host memory and returnscudaSuccess— 28 GiB succeeds with 22.6 GiB reported free. Measure bandwidth to tell resident from spilled: ~1530 GB/s vs ~237 GB/s. That 6.5x cliff is the mechanism behind #1103 (55 vs 391 tok/s), and it is why "0 MiB free" is a correctness problem and not just a tight fit. - Free VRAM only ever decreases within a process. WSL2/WDDM never returns a
process's peak commitment, even though every CUDA-level release succeeds. Any
sizing decision read from
cudaMemGetInfois reading a moving floor — which is the whole argument for planning capacity instead of discovering it.
Before using the GPU, ALWAYS check that it is free. Run any GPU job (make test-gpu, benchmarks, profiling, inference) only after confirming no other process is using the card: docker ps -q | wc -l MUST be 0 and nvidia-smi must show no active compute processes (idle ~30°C, low power draw). A busy GPU corrupts benchmark numbers and can OOM; never start GPU work on a card that is already in use.
The host has no CUDA toolkit by design — build inside Docker. build/ is created root-owned by the container; remove it via a throwaway container, never sudo on the host.
make dev # INCREMENTAL build — use this to iterate (see below)
make dev-test # incremental build + `ctest -L unit` (the actual CI lane)
make build # full Docker image (CUDA 13.3) — the gate, not the loop
make test-unit # CPU/host unit tests (imp-tests-unit — NOT the CI lane)
make test-gpu # GPU tests (requires RTX 5090)
make verify-fast # ~90s pre-push gate
make verify # ~5min full
make install-hooks # pre-push hook: runs verify-fast on src/include/tools/tests changes
make format # clang-format
- Never run bare
make format. The repo is not uniformly clang-formatted (untouched files violate the current style), so formatting whole files rewrites hundreds of lines you did not touch. CI only checks changed lines. Format the files you created; for files you edited, format nothing and hand-fix only the lines you added. To check yourself, intersect the violation list with your own added lines rather than trusting a cleanformat-check. - Targets exist for
bench,test-perf,test-golden,test-vision,gen-perf-baseline,verify-north-star,verify-chunked,tidy(clang-tidy, advisory),sanitize, and the roofline pipeline (roofline-measure,roofline-pin,roofline-regress— seetools/roofline/README.md). - Configure presets live in
CMakePresets.json(default/ci/debug/relwithdebinfo); dependency pins are single-sourced incmake/imp-deps.cmake(the Dockerfile takes them as build-args). sm_120a is selected via raw gencode (CMake <3.31 workaround); 120f PTX fallback covers non-5090 SKUs. - See also:
AGENTS.md(subagent roles + guardrails) anddocs/BENCHMARKING.md(measurement methodology contract).
make build recompiles the whole tree inside a fresh image every time — ~3.5 min
whether you changed one line or a hundred. Ten edit-compile cycles is half an hour
of waiting, and that is the single largest avoidable cost of working here.
make dev mounts the tree into the toolchain image and runs ninja against a
persistent build-dev/. Measured on this box:
| after… | make build |
make dev |
|---|---|---|
| nothing | ~210 s | 2.4 s |
| one test file | ~210 s | 4.9 s |
one kernel .cu |
~210 s | 6.8 s |
| one server TU | ~210 s | 13.9 s |
Codegen is identical (both -march=x86-64-v3, same toolchain layers), so dev
binaries are valid to run tests against — make dev-test runs ctest -L unit,
which is the lane CI actually gates on (make test-unit runs a different
binary; green there is not green in CI).
Where make dev is the wrong tool:
- Benchmarks and the perf gate. Always measure the image. An incremental tree is exactly where a stale object hides, and baselines get re-pinned off measured numbers.
- Before pushing.
verify-fastand CI build the image from a clean tree. build-dev/is root-owned; remove it withmake dev-clean, neversudo.
- English only in the repo. All PRs (title + body), commit messages, code comments, docs, and
.mdfiles are written entirely in English. (Chat replies to the user stay German per global instructions — this rule covers artifacts that land in the repository or on GitHub.) - Always branch off
mainandgh pr create --base main. Never stack PRs (squash-merge + stacking caused recovery-PR cascades). Prefer fewer, batched PRs over one-per-fix. - Performance is gated.
tests/perf_baseline.jsonis the canonical baseline (CI: 3% decode / 5% prefill thresholds). Refresh viascripts/gen_perf_baseline.shwhen a change intentionally moves perf, and say so in the PR. - Runtime config lives in
src/runtime/config.hasRuntimeConfig(loaded fromimp.conf+--config+--set; the only env vars still seeded areIMP_DETERMINISTICandIMP_FMHA_FA2— the rest of the legacyIMP_*surface was retired 2026-07-07). Don't reintroduce ad-hoc env-var reads. - Internal errors throw and are translated to
ImpErrorat thesrc/api/imp_api.cppboundary — this is intentional, don't convert those throws to status returns. - Match surrounding code style; keep it simple and direct, no speculative abstraction.
The real cost of an oversized file here is recompile blast radius, not line count.
Each .cu is one translation unit: touching a kernel in a 1.5k-LOC .cu re-ptxases
the whole TU (the slowest sm_120a build stage) with no intra-file parallelism, and a
fat header re-triggers every includer. Optimize for compile-time isolation:
- One logical unit per file — one kernel concept, one module. If a
.cubundles several unrelated kernels, that's a split candidate. - Separate kernel definition / host launch-wrapper / explicit template
instantiations. When recompiles bite, move explicit instantiations into their own
.cuso a kernel edit doesn't re-build every instantiation. - Line-count thresholds are a proxy/smell, not the metric. The gate
tools/check_filesize.py(configtools/filesize_thresholds.toml) measures code LOC (comments + blanks stripped) per category — kernel.cuwarn>500/hard>600, normal TU warn>600/hard>800, header warn>500/hard>700. It runs in CI as theFile sizejob (advisory warn step + blocking hard step); a hard-review violation fails CI. - A legitimately monolithic file is fine — add it to
[allow]in the toml with a reason (the gate rejects an empty reason). Don't split for splitting's sake. Current baseline + per-file rationale:docs/audit/AUDIT_FILESIZE.md.
- The GPU is water-cooled and never warm — do NOT insert temperature cooldown waits between benchmarks (it idles ~30°C and does not thermal-throttle). A decode drop across a sweep is a real regression or stale baseline, never heat.
- The GPU downclocks at idle and takes ~1s for clocks to ramp up under load. The first ~1s of any benchmark runs at reduced clocks and reads artificially LOW — this (not heat) is the dominant cold-start bench artifact (it produced a spurious −42% in one A/B that re-measured +20% clean). Always warm the clocks before timed reps: precede the measured run with a discarded warmup run (or busy-spin >1s); imp's built-in
Warmup...is too few iterations to cover the 1s ramp. Never trust a cold single-shot number. - cuBLAS prefill varies up to 2.6× across container restarts and drifts under sustained load — use decode for A/B and follow the bench methodology (
CUBLAS_WORKSPACE_CONFIG=:4096:8, 10 reps, 3+ trials, one model per process / isolated). - Decode is stable within a session but can read 8–15% low for a whole day (host/driver state on this WSL2 box; issue #526: identical builds measured tg 238 on one day, 278 the next — full methodology both times). Before trusting a cross-day decode delta or refreshing the baseline, sample
nvidia-smi --query-gpu=clocks.sm,clocks.mem,power.drawDURING the bench: healthy load = ~2850 MHz SM / 13801 MHz mem / ~500 W. Lower mem clock or power = depressed host state, not a regression. GPU history:gpu-statsskill (7-day retention). - nsys with CUDA Graphs ON hides captured kernels — profile with
--no-cuda-graphsto see the true decode kernel mix. gemm.deterministicis not the determinism switch —runtime.deterministic_gemmis. On FA2-served configs the engine skips the legacy deterministic-cuBLAS forcing and says so in the init log. Without the right flag the forward varies run to run: two identical calibration passes differed on 94% of recorded per-channel floats and moved a quantized model's PPL by 1.6% (2026-07-31). Anything you intend to diff across runs — calibration files, layer dumps, logit traces — needsruntime.deterministic_gemm=true, and it is worth asserting reproducibility (run twice, compare checksums) before drawing a conclusion from the difference.- Container-written outputs are owned by the container user.
imp-quantize --out, calibration files andbuild/cannot be deleted from the host; a failedrm -rfleaves a stale directory that the next run only partly overwrites — which looks like a result, not an error. Clear them from a throwaway container (docker run --rm -v … --entrypoint /bin/sh imp:test -c 'rm -rf …'), and make bind-mounted output dirs writable (chmod 777) before the container writes into them.
- No
tcgen05/ TMEM / wgmma / TMA-WS grouped GEMM. FP4 path ismma.syncmxf4nvf4with FA2-style block-scaling. Ignore B200/sm_100(FlashAttention-4-style) kernel designs unless porting. - No FP4 cuBLASLt kernels on sm_120 → CUTLASS is the primary GEMM path. Dependency pins are single-sourced in
cmake/imp-deps.cmake(the Dockerfile takes them as build-args viascripts/dep_build_args.sh; bump only that one file). FP8 prefill is unavailable on sm_120. - For GGUF, weights are converted to an NVFP4 decode cache at init (bandwidth win on the decode hot path); prefill stays full-precision via source dequant.
nvfp4_beneficialweights skip the FP16 cache. - Docker build cache must not use
--mount=type=cache(silently invalidates test results).
Verify the model still produces coherent output (no repetition loops / token-stuck / state corruption) after touching the forward pass, MoE routing, KV cache, GDN state, or CUDA-graph capture.