Skip to content

Commit ec46696

Browse files
authored
Add authenticated hosted NCU profiling with capture options (#530)
1 parent 30ba5ce commit ec46696

8 files changed

Lines changed: 439 additions & 54 deletions

File tree

‎docs/hosted-ncu-profiling.md‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Hosted Nsight Compute profiling
2+
3+
`popcorn submit submission.py --leaderboard qr_v2 --profile --benchmark-index 0`
4+
uses the normal Popcorn identity and API. Users do not install Modal, create a
5+
provider account, or supply provider credentials. KernelBot dispatches the job
6+
to its GPU runner and streams back reports. `--profile-brev` remains an explicit,
7+
separate CLI choice; failures never switch providers.
8+
9+
## API and capture contract
10+
11+
`POST /profile/{leaderboard_name}/{gpu_type}` accepts the usual
12+
`X-Popcorn-Cli-Id` header and a multipart `file`, plus optional fields:
13+
14+
| Field | Meaning |
15+
| --- | --- |
16+
| `benchmark_index` | Zero-based index in the task's `benchmarks`; omit for all entries. |
17+
| `ncu_kernel_name` | NCU kernel filter, including `regex:` expressions. |
18+
| `ncu_kernel_name_base` | `function`, `demangled`, or `mangled`. |
19+
| `ncu_launch_count` | Positive capture limit per benchmark; default 10. |
20+
21+
The route retains normal submission permissions, limits, and leaderboard GPU
22+
validation. It supports the configured single-GPU NVIDIA Modal runners. Invalid
23+
options and unsupported GPUs are rejected; unsupported evaluators fail with NCU
24+
diagnostics. The dedicated route prevents older API versions from silently
25+
ignoring capture options.
26+
27+
The runner sets `POPCORN_NCU=1`, follows child processes, and captures the NVTX
28+
push/pop range `custom_kernel/`. It collects the full NCU section set with kernel
29+
replay and leaves GPU clocks unchanged. Each benchmark returns a zip containing
30+
`profile.ncu-rep`, `ncu-details.txt`, and `ncu-details.csv`. A missing capture is a
31+
failure, even when the profiler process exits successfully.
32+
33+
`FullResult.profile_metadata` records selected benchmark specifications, capture
34+
options, and a SHA-256 fingerprint of source/configuration fields. This is not a
35+
git commit. Record the synced reference-kernels revision in operator validation
36+
records; the client cannot update the hosted task through a local checkout.
37+
38+
## Rollout
39+
40+
Deploy the updated GPU runners and API before releasing the companion
41+
[popcorn-cli change](https://github.com/gpu-mode/popcorn-cli/pull/80). The runner
42+
image pins NCU 2025.2.1 and checks `ncu --version` during its build. NCU 2026.2
43+
from CUDA 13.3 produced many NaN counters on the tested Modal B200; repeating
44+
the same capture with 2025.2.1 restored hardware counters. Use the existing Modal deployment
45+
workflow and API deployment process; no additional container privileges or
46+
user-facing provider setup is introduced.
47+
48+
Sync an NCU-compatible task through the existing problem-update workflow, then
49+
validate one benchmark through the public API using an ordinary Popcorn user.
50+
Inspect the returned report and counter exports. The
51+
[reference-kernels guide](https://github.com/gpu-mode/reference-kernels/pull/171)
52+
describes the evaluator contract for new problems. Merely accepting `profile`
53+
and running `torch.profiler` does not satisfy that contract.
54+
55+
## Validation
56+
57+
CPU tests cover API authentication and options, benchmark selection, capture
58+
commands, exports, and empty captures. The GPU fixture is
59+
`problems/linalg/qr_v2`, benchmark 0 (`batch=20, n=32, cond=1, seed=43214`), at
60+
reference-kernels `51e22db671d36c1c76091c43c36a44546ba324a1`.
61+
Only this problem/shape is used for the GPU integration check; other tasks and
62+
GPU types require their own validation.
63+
64+
The integration check used the real FastAPI route, submission preparation,
65+
KernelBackend, and GPU `run_config`, with a fake database and an ephemeral
66+
launcher instead of production infrastructure. The CLI used normal Popcorn
67+
header authentication with an empty `PATH` and no `MODAL_*` environment values.
68+
It successfully saved and extracted a 7,593,257-byte report and both detail
69+
exports. NCU reopened the report to produce those exports.
70+
71+
NCU 2025.2.1 collected 39 passes on NVIDIA B200 with PyTorch 2.12.0+cu130,
72+
CUDA 13.3, and `regex:geqr2` / demangled / launch count 1. Observed counters:
73+
322.78 us duration, 12.51% achieved occupancy, 0.42% SM throughput, 70.09% L2
74+
hit rate, and 1,565,428 executed instructions. Six `ctc__*` metrics remained
75+
unavailable. This validates the isolated implementation; the production API
76+
has not been deployed as part of this change.
77+
78+
[Ephemeral validation run](https://modal.com/apps/coreauto/main/ap-p8eodYfHkZAMp0ZT5btR1y)
79+
(access requires the operator's workspace permissions).

‎src/kernelbot/api/main.py‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,18 @@
88
from dataclasses import asdict
99
from typing import Annotated, Any, Optional
1010

11-
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, UploadFile
11+
from fastapi import Depends, FastAPI, Form, Header, HTTPException, Query, Request, UploadFile
1212
from fastapi.responses import JSONResponse, StreamingResponse
1313

1414
from kernelbot.env import env
1515
from libkernelbot.application_validation import ApplicationValidationService
1616
from libkernelbot.backend import KernelBackend
1717
from libkernelbot.background_submission_manager import BackgroundSubmissionManager
18-
from libkernelbot.consts import SubmissionMode
18+
from libkernelbot.consts import SubmissionMode, get_gpu_by_name
1919
from libkernelbot.db_types import IdentityType
2020
from libkernelbot.leaderboard_db import LeaderboardDB, LeaderboardRankedEntry
2121
from libkernelbot.problem_sync import sync_problems
22+
from libkernelbot.profiling import ProfileOptions
2223
from libkernelbot.submission import (
2324
ProcessedSubmissionRequest,
2425
SubmissionRequest,
@@ -522,6 +523,43 @@ async def admin_run_application_validation(
522523
}
523524

524525

526+
@app.post("/profile/{leaderboard_name}/{gpu_type}")
527+
async def profile_submission(
528+
leaderboard_name: str,
529+
gpu_type: str,
530+
file: UploadFile,
531+
user_info: Annotated[dict, Depends(validate_cli_header)],
532+
benchmark_index: Annotated[int | None, Form(ge=0)] = None,
533+
ncu_kernel_name: Annotated[str | None, Form(min_length=1, max_length=1024)] = None,
534+
ncu_kernel_name_base: Annotated[str | None, Form()] = None,
535+
ncu_launch_count: Annotated[int | None, Form(ge=1)] = None,
536+
db_context=Depends(get_db),
537+
) -> StreamingResponse:
538+
"""Profile through the normal authenticated submission pipeline.
539+
540+
A dedicated route prevents older API deployments from silently ignoring
541+
capture options sent by a newer CLI. Compute-provider credentials stay here.
542+
"""
543+
await simple_rate_limit()
544+
try:
545+
gpu = get_gpu_by_name(gpu_type)
546+
if gpu is None or gpu.runner != "Modal" or gpu.name == "L4x4":
547+
raise ValueError("Hosted NCU profiling requires a supported single NVIDIA GPU")
548+
options = ProfileOptions(
549+
benchmark_index, ncu_kernel_name, ncu_kernel_name_base, ncu_launch_count
550+
)
551+
except (ValueError, KeyError) as error:
552+
raise HTTPException(status_code=400, detail=str(error)) from error
553+
request, mode = await to_submit_info(
554+
user_info, "profile", file, leaderboard_name, gpu_type, db_context
555+
)
556+
request.profile_options = options.to_dict()
557+
return StreamingResponse(
558+
_stream_submission_response(request, mode, backend_instance),
559+
media_type="text/event-stream",
560+
)
561+
562+
525563
@app.post("/{leaderboard_name}/{gpu_type}/{submission_mode}")
526564
async def run_submission( # noqa: C901
527565
leaderboard_name: str,

‎src/libkernelbot/backend.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ async def submit_full(
136136
req.task,
137137
mode,
138138
None,
139+
**(
140+
{"profile_options": req.profile_options}
141+
if req.profile_options is not None else {}
142+
),
139143
)
140144
for gpu in selected_gpus
141145
]
@@ -174,6 +178,7 @@ async def submit_leaderboard( # noqa: C901
174178
task: LeaderboardTask,
175179
mode: SubmissionMode,
176180
seed: Optional[int],
181+
profile_options: dict | None = None,
177182
) -> Optional[FullResult]:
178183
"""
179184
Function invoked by `leaderboard_cog` to handle a leaderboard run.
@@ -193,6 +198,7 @@ async def submit_leaderboard( # noqa: C901
193198
task=task,
194199
mode=mode,
195200
submission_id=submission_id,
201+
**({"profile_options": profile_options} if profile_options is not None else {}),
196202
)
197203

198204
if result.success:
@@ -232,6 +238,7 @@ async def handle_submission(
232238
task: Optional[LeaderboardTask],
233239
mode: SubmissionMode,
234240
submission_id: int = -1,
241+
profile_options: dict | None = None,
235242
) -> Optional[FullResult]:
236243
"""
237244
Generic function to handle code submissions.
@@ -250,6 +257,9 @@ async def handle_submission(
250257
task=task, submission_content=code, arch=self._get_arch(gpu_type), mode=mode
251258
)
252259

260+
if profile_options is not None:
261+
config["profile_options"] = profile_options
262+
253263
logger.info("submitting task to runner %s", launcher.name)
254264

255265
result = await launcher.run_submission(config, gpu_type, reporter)

‎src/libkernelbot/profiling.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Validated, request-scoped profiling options shared by API and GPU runners."""
2+
3+
from dataclasses import asdict, dataclass
4+
from typing import Literal
5+
6+
7+
@dataclass(frozen=True)
8+
class ProfileOptions:
9+
benchmark_index: int | None = None
10+
ncu_kernel_name: str | None = None
11+
ncu_kernel_name_base: Literal["function", "demangled", "mangled"] | None = None
12+
ncu_launch_count: int | None = None
13+
14+
def __post_init__(self):
15+
for name, minimum in [("benchmark_index", 0), ("ncu_launch_count", 1)]:
16+
value = getattr(self, name)
17+
if value is not None and (type(value) is not int or value < minimum):
18+
raise ValueError(f"{name} must be an integer >= {minimum}")
19+
if self.ncu_kernel_name_base not in (None, "function", "demangled", "mangled"):
20+
raise ValueError("ncu_kernel_name_base must be function, demangled, or mangled")
21+
if self.ncu_kernel_name is not None and (
22+
not isinstance(self.ncu_kernel_name, str)
23+
or not self.ncu_kernel_name
24+
or len(self.ncu_kernel_name) > 1024
25+
or "\x00" in self.ncu_kernel_name
26+
):
27+
raise ValueError("ncu_kernel_name must contain 1..1024 characters without NUL")
28+
29+
def validate_task(self, benchmarks: list, multi_gpu: bool):
30+
if multi_gpu:
31+
raise ValueError("NCU profiling requires a single GPU")
32+
if not benchmarks:
33+
raise ValueError("This task has no benchmarks to profile")
34+
if self.benchmark_index is not None and self.benchmark_index >= len(benchmarks):
35+
raise ValueError(f"benchmark_index must be between 0 and {len(benchmarks) - 1}")
36+
37+
def to_dict(self) -> dict:
38+
return {name: value for name, value in asdict(self).items() if value is not None}

0 commit comments

Comments
 (0)