From 74f899379534e5eb199d9767b4b8d210d509db73 Mon Sep 17 00:00:00 2001 From: diego Date: Tue, 25 Aug 2026 17:05:14 -0300 Subject: [PATCH] gpu: project the PC-sample label set (source, attribution, PC budget) Task 9 of the PC-sampling plan, minus gpu_serialized. Every sample projected from a GPUPCSample now carries, on top of the gpu_stall and gpu_pc it already had: gpu_pc_attrib how the sample reached this execution - unconditional gpu_src_status why it does or does not have a source location - unconditional, four values, decided by ModuleStore alone gpu_src_file the source file's BASENAME, only under "resolved" gpu_src_line the source line, only under "resolved" gpu_src_func the device function, only under "resolved" The two unconditional ones are unconditional for gpu_join's reason: an absent label must never be readable as a positive answer by a consumer who does not know to check for its absence. A nil ModuleStore is answered by an empty store rather than by a branch here, so the projection never decides the enum - "no store" and "the cubin never arrived" are the same fact and get the same answer. gpu_src_file drops the directory. A cubin's DWARF carries build-host absolute paths, which vary per build, cost a long string in the string table for nothing a reader acts on, and leak the build environment's layout into a profile that may be shared. ProjectExecutionsWith caps distinct gpu_pc values per projection (default 20,000 - the top of the range the design already calls tolerable, reasoned rather than measured). Past the ceiling gpu_pc is dropped and counted in ProjectionStats.PCLabelsSuppressed; gpu_stall, gpu_pc_attrib and gpu_src_* survive, and the sample keeps its full share of the execution's duration. An offset already emitted is always readmitted - the cap bounds the string table, and a repeat costs it nothing. JoinHealthWith raises the suppression as an anomaly, because a profile that silently lost its PC labels looks identical to one that never had any; both drivers were switched over so the counter is actually printed. Reserved names now win by ABSENCE too. The new labels are conditional, so a producer tag named gpu_src_file would have survived untouched in exactly the cases where this package derives no value - a forged source location standing beside gpu_src_status="no-module". projectionLabels clears every per-PC-sample reserved name straight after the Tags copy, which also closes the same hole for gpu_stall when no reason was named and for every one of these names on a no-PC-sample execution. Frames gain nothing, asserted negatively. gpu_serialized is deliberately not here: its three values come from Tier A's sampling windows, which do not exist yet, and the only default it could take is "false" - the one answer the design says must never be reachable by accident. Absent is honest; present and meaningless is not. Seven mutations checked, each caught. See .superpowers/sdd/task-9-labels-report.md. --- .superpowers/sdd/task-9-labels-report.md | 343 ++++++++++++++++ cmd/gpu-cuda-profile/main.go | 10 +- cmd/gpu-stub-profile/main.go | 10 +- gpu/joinhealth.go | 33 +- gpu/projection.go | 324 ++++++++++++++- gpu/projection_test.go | 500 ++++++++++++++++++++++- 6 files changed, 1206 insertions(+), 14 deletions(-) create mode 100644 .superpowers/sdd/task-9-labels-report.md diff --git a/.superpowers/sdd/task-9-labels-report.md b/.superpowers/sdd/task-9-labels-report.md new file mode 100644 index 0000000..904d67a --- /dev/null +++ b/.superpowers/sdd/task-9-labels-report.md @@ -0,0 +1,343 @@ +# Task 9 — the projection label set + +Branch `feat/pc-projection-labels`, one commit off `main` +(`b1468a7a`, the Task 8b merge). Plan: +`docs/superpowers/plans/2026-08-25-gpu-pc-sampling.md`, Task 9. + +Five files: `gpu/projection.go`, `gpu/joinhealth.go`, +`gpu/projection_test.go`, and the two drivers +(`cmd/gpu-stub-profile/main.go`, `cmd/gpu-cuda-profile/main.go`) so the new +counter actually reaches an operator. + +**`gpu_serialized` is NOT in this commit.** See §7. Everything else Task 9 +lists is. + +--- + +## 1. The labels, and when each is emitted + +Every one of these is written **after** the `Tags` copy, on a clone of the +execution's shared label map, so a producer-supplied tag can never forge one. +Frames are untouched. + +| label | on | when | +| --- | --- | --- | +| `gpu_stall` | PC-derived samples | unchanged — when the producer named a reason | +| `gpu_pc` | PC-derived samples | unchanged — subject to the new cardinality budget (§3) | +| `gpu_pc_attrib` | PC-derived samples | **unconditional** | +| `gpu_src_status` | PC-derived samples | **unconditional** | +| `gpu_src_file` | PC-derived samples | only under `resolved`, **basename only** (§2) | +| `gpu_src_line` | PC-derived samples | only under `resolved` | +| `gpu_src_func` | PC-derived samples | only under `resolved` | + +"PC-derived sample" means a sample projected from a `GPUPCSample`. An +execution carrying no PC samples projects one sample with none of these — it +has nothing to say about an instruction that was never sampled. + +### `gpu_src_status` — four values, decided nowhere but the store + +`ModuleStore.Resolve(crc, functionIndex, pcOffset)` returns a `Resolution` +whose `Status()` is one of `resolved` / `no-lineinfo` / `no-module` / +`unmapped`, and the projection **spells** it rather than deciding it. That +was already structurally enforced by Task 4 (`Resolution` has unexported +fields and no exported constructor, and its location is reachable only +through `Source`, which returns `ok` in the same expression as the data), and +this task did not weaken it: `setSourceLabels` takes the `ok` and returns +early, so there is no branch anywhere that could pair a file with +`no-lineinfo`. + +It is unconditional for `gpu_join`'s reason, and that is the property the +brief singled out: an absent label must never be readable as a positive +answer. Absence here would read as "this sample was never source-mapped", +which is a fourth fact the four values do not include and the one a reader +would wrongly assume. + +**A nil `ModuleStore` is answered by an empty store, not by the projection.** +`ProjectionConfig.Modules` is nil for both shipping drivers today (nothing +feeds cubins yet). Rather than branch — which would put a second decision +site for the enum into the codebase, and the first place a fifth value could +appear — `ProjectExecutionsWith` constructs `NewModuleStore(ModuleStoreConfig{})` +and asks it. An empty store answers `no-module` to everything, which is +exactly true: no usable module bytes exist for that CRC. This is the same +accounting Task 8b chose for a nil store at the join +(`PCJoinStats.GroupsUnresolvedName`), for the same reason — a missing store +must not look like a healthy run. + +### `gpu_pc_attrib` — and what a join bug looks like + +Read straight off `ExecutionView.PCAttrib`, which Task 8b decides. Also +unconditional. + +The one addition: a view carrying PC samples with an **empty or fabricated** +`PCAttrib` renders `"unset-pc-attrib"` rather than being omitted. That is not +a fifth value of the label's domain — it is what a join bug looks like from +outside, in the same shape and for the same reason as `SrcStatus.String`'s +`"unset-src-status"`. Omitting the label instead would hide the bug behind +the single reading that must never be reachable by accident (`exact` is the +only one of the four that is not an inference). The conformance suite's +`assertPCAttribAccompaniesSamples` already makes this unreachable through the +real join; this is the belt for the profile a consumer actually reads. + +--- + +## 2. `gpu_src_file` carries the basename; the directory goes nowhere + +`srcFileBase` reduces the line table's file name to its last path element. + +This is not cosmetic. The Task 1 fixtures resolve to +`/tmp/perf-agent-cubin-fixtures/single.cu` — a real build-host absolute path, +straight out of the cubin's DWARF. Carrying it costs three ways: it varies +per build, so the same kernel built twice yields two distinct label values +for one file; it is a long string in the pprof string table that no reader +acts on; and it leaks the build environment's layout into a profile that may +be shared outside the organisation that produced it. The basename beside +`gpu_src_func` is enough to find the line in the repository it came from, +which is the only thing a reader does with it. + +`path.Base`, not `filepath.Base`: the separator in a cubin's line table is +the **build host's**, and the result must not depend on the agent's own OS. +The agent is Linux-only and nvcc emits `/` there. A Windows-built cubin's +backslashes would not be split; that is stated rather than guessed at, +because a filename may legally contain a backslash on Linux and splitting on +it would corrupt a legitimate name. + +`TestProjectionSrcFileIsABasenameNotABuildHostPath` asserts the basename +**and** that no other label smuggles the directory back in. + +--- + +## 3. The `gpu_pc` cap + +`ProjectionConfig.MaxDistinctPCLabels`, tracked per `ProjectExecutions` call +by `pcLabelBudget`. Past the ceiling, `gpu_pc` is dropped and every dropped +sample is counted in `ProjectionStats.PCLabelsSuppressed` (JSON +`projection_pc_labels_suppressed` — the design's `ProjectionPCLabelsSuppressed`). + +**Only `gpu_pc` gives way.** `gpu_stall`, `gpu_pc_attrib` and the whole +`gpu_src_*` family are untouched, and the sample keeps its full share of the +execution's duration — the suppression costs a label, never a measurement. +`gpu_pc` is the most numerous label in the set (one value per distinct +sampled instruction) and the least actionable alone: a bare offset tells a +reader nothing that the stall reason and the source line do not tell them +better. + +**An offset already emitted is always readmitted.** The cap bounds the pprof +**string table**, which stores one entry per distinct label *value*; a repeat +costs nothing there. So the rule is "admit no NEW value past the ceiling", +not "emit nothing past the ceiling". Distinct values are bounded at exactly +the ceiling either way, and the second reading would discard strictly more of +the profile for no saving. Suppression is counted per **sample**, because the +sample is what went out incomplete. + +`%#x` is injective over `uint64`, so the set of admitted offsets is exactly +the set of distinct label values — the budget's `map[uint64]struct{}` is not +an approximation of what it bounds. + +### The ceiling: 20,000, and on what basis + +`defaultMaxDistinctPCLabels = 20_000`. **Reasoned, not measured.** The number +is the design's own pathological estimate: 20,000 distinct PCs ≈ 400 KB of +string table pre-gzip, ~140 KB after, which the design calls *tolerable*. +Setting the ceiling at the top of the range the design already accepted means +it cannot fire on any workload that design considered reasonable, and fires +only past it. `gpu_pc` saturates — once every hot instruction has been +sampled once, a longer run adds no new values — so this bounds the profile's +string table, not its length. §8 says what would turn it into a measured +number. + +### Surfacing it + +`JoinHealthWith(snap, ProjectionStats)` raises an anomaly whenever +`PCLabelsSuppressed > 0`, naming the ceiling and the two things that cause it +(a workload with far more distinct hot code than the budget expects, or a cap +set too low). `JoinHealth(snap)` is now a wrapper passing the zero stats, so +every existing caller and test is unchanged. + +The split exists because `ProjectionStats` cannot be in the `Snapshot` — it +is produced by the projection, which runs *after* the snapshot is taken. It +is the same shape as `SinkStats`, which `Timeline` also cannot see and which +a caller supplies through `CountingSink.SnapshotWith`. + +**Both drivers were switched to `ProjectExecutionsWith` + `JoinHealthWith`.** +A counter no shipping path prints is exactly the kind of thing that reads +green when it matters; this is a two-line change in each driver and nothing +else about them moved. + +--- + +## 4. The anti-forgery extension — including the direction the conditionals opened + +`TestProjectionReservedLabelsWinOverTags` grew five names +(`gpu_pc_attrib`, `gpu_src_status`, `gpu_src_file`, `gpu_src_line`, +`gpu_src_func`), now driven through a real module store so the resolved +values exist to win. The `gpu_src_*` family is the sharpest case in the whole +label set: those labels name a file and a line in the profiled program's own +source, which is the single most believable thing a profile can say, so a tag +that could set them would let a producer point every stalled instruction at a +source line of its choosing. + +**Overwriting alone turned out to be insufficient, and this is the one +substantive addition beyond the plan's letter.** The new labels are +*conditional*: `gpu_src_file` only under `resolved`, `gpu_pc` only inside the +budget, `gpu_stall` only when the producer named a reason, and none of them +on an execution with no PC samples. A tag named `gpu_src_file` would +therefore have survived untouched in exactly the cases where this package has +no value of its own — a forged source location standing beside +`gpu_src_status="no-module"`, which is worse than any value it could have +overwritten. So `projectionLabels` now **clears** every per-PC-sample +reserved name (`pcSampleReservedLabels`) immediately after the `Tags` copy, +before anything is derived. Reserved names win by absence too. + +That also closes the same pre-existing hole for `gpu_stall` (a tag survived +whenever the producer named no stall reason) and for every one of these names +on a no-PC-sample execution. `gpu_stall`'s and `gpu_pc`'s emission is +otherwise exactly as it was. + +`TestProjectionSourceLabelsCannotBeForgedByAbsence` drives all three shapes +in one snapshot: an unresolvable module, a sample past the cap, and an +execution with no PC samples. + +--- + +## 5. Frames do not change + +`projectionFrames` is byte-for-byte unmodified. `TestProjectionAddsNoFrames` +asserts it negatively: no frame name contains `gpu:pc`, `gpu:src`, a stall +reason, a source file name, a rendered PC offset, or an attribution value, +and the two PC samples of one kernel share one stack. The kernel name is +deliberately *not* on the forbidden list — `[gpu:kernel:]` is one of +the three frames the design fixes. + +--- + +## 6. Verification + +`CapEff: 0`, no GPU, no capabilities, no BPF, no CUPTI, no shim. From the +worktree with the plan's build environment. + +``` +go build ./... ok +go vet ./... ok +go test ./gpu/ ./gpuprobe/ ./internal/... -count=1 ok (12 packages) +go test ./... -count=1 ok (whole repo, no regressions) +go test ./gpu/ -race -count=4 ok 22.4s +~/go/bin/golangci-lint run --timeout=5m 0 issues. +``` + +The plan's offline checks for this task, and the test that is each one: + +| the plan asks | test | +| --- | --- | +| all four `gpu_src_status` values | `TestProjectionEmitsAllFourSrcStatuses` (exhaustive against `SrcStatuses()`) | +| all four `gpu_pc_attrib` values | `TestProjectionEmitsAllFourPCAttribValues` (exhaustive against `PCAttribs()`) | +| `kernel-ambiguous` never coincides with `gpu_ambiguous="true"` | `TestProjectionKernelAmbiguousNeverCoincidesWithGpuAmbiguous` (end to end through the real join) | +| a `Tags` entry named `gpu_src_file` loses to the derived value | `TestProjectionReservedLabelsWinOverTags`, plus `…CannotBeForgedByAbsence` for the case with no derived value | +| the cap suppresses `gpu_pc` and **only** `gpu_pc`, exact count | `TestProjectionCapSuppressesGpuPCAndOnlyGpuPC` (5 offsets, ceiling 2, exactly 3 suppressed) | +| no frame carries `gpu:pc`, `gpu:src` or a stall reason | `TestProjectionAddsNoFrames` | + +Nine new tests plus one extended: also +`TestProjectionWithoutAModuleStoreStillAnswersEveryPCSample`, +`TestProjectionSrcFileIsABasenameNotABuildHostPath`, +`TestSrcFileBaseRejectsWhatIsNotAName`, +`TestProjectionPCAttribIsNeverSilentlyAbsent`, +`TestProjectionCapReadmitsAnOffsetItAlreadyEmitted`, +`TestProjectionCapIsSurfacedInJoinHealth`, +`TestProjectionSourceLabelsCannotBeForgedByAbsence`. + +### Mutation checks — the tests bite + +Each applied alone to the finished code, `go test ./gpu/ -count=1`: + +| mutation | caught by | +| --- | --- | +| M1 — `gpu_src_status` made conditional on a resolved status | 3 tests, incl. `…EmitsAllFourSrcStatuses` | +| M2 — the directory kept instead of the basename | 5 tests, incl. `…SrcFileIsABasenameNotABuildHostPath` | +| M3 — the cap also drops `gpu_stall` | `…CapSuppressesGpuPCAndOnlyGpuPC` | +| M4 — `gpu_pc_attrib` omitted when the join decided nothing | `…PCAttribIsNeverSilentlyAbsent` | +| M5 — reserved names no longer cleared from producer `Tags` | `…SourceLabelsCannotBeForgedByAbsence` | +| M6 — suppression not surfaced in `joinhealth` | `…CapIsSurfacedInJoinHealth` | +| M7 — the cap never fires | 4 tests | + +A test that cannot fail is not a test. + +--- + +## 7. `gpu_serialized` is deferred to Task 10 — deliberately absent + +The design puts a fourth group in this task: `gpu_serialized`, unconditional +on **every** execution, `"true"` / `"false"` / `"unknown"`. **It is not in +this commit, not stubbed, and not present with a default value.** + +Its three values are decided by the Tier A sampling windows +(`gpu_sampling_window_v1`), which Task 10 introduces and which do not exist +in this tree. There is no way to compute the label here, and the only value +it could take by default is `"false"` — which the design names as the one +answer that must never be reachable by accident, because "not perturbed" and +"cannot tell" are different facts and a profile that confuses them is +precisely the failure §4 forbids. A `gpu_serialized` that is present and +meaningless is worse than one that is absent; absent is honest while Tier A +does not exist. + +**Follow-up for Task 10:** add it in `projectionLabels` (it rides on every +execution, not only PC-bearing ones, so it belongs in the shared map rather +than the per-PC-sample block), add its name to `pcSampleReservedLabels`' +equivalent for the shared map — or, more simply, set it after the `Tags` copy +like every other label there — and extend +`TestProjectionReservedLabelsWinOverTags` with it. Task 10's own honesty +obligations (an `end_ns == 0` window is open, not zero-length; missing +windows are `"unknown"`, never `"false"`) are label *inputs* and belong with +the windows, not here. + +Also carried forward from the design and **not** this task's business: CPU +and off-CPU samples taken during a Tier A burst are distorted and carry no +marking at all, because those profilers are a separate path with no window +awareness. Task 11's standing operator warning is where that is disclosed. + +--- + +## 8. Cannot verify + +**Nothing here has been run against a GPU, and no claim in this report is +about hardware.** `CapEff: 0`, no NVIDIA device. Every PC sample these labels +have ever described is synthetic, and every module they resolved against is +one of Task 1's `sm_86` / CUDA 13.3 fixtures. + +The plan defers exactly one thing about this task to the RTX 3090, and it is +open: + +- **The real distinct-value counts per label on a genuine profile** — which + is what would confirm the design's cardinality table and turn the `gpu_pc` + ceiling into a measured number instead of the reasoned 20,000 in §3. The + instrument is in the output: `ProjectionStats.DistinctPCLabels` beside + `PCLabelCap` on every projection, so "we were nowhere near the ceiling" and + "we sat exactly on it" are distinguishable without recomputing anything + from the profile. The reading that says the ceiling is wrong is + `PCLabelsSuppressed > 0` on an ordinary workload; the reading that says it + is generous is `DistinctPCLabels` an order of magnitude below `PCLabelCap` + on the hottest run available. + +Unestablished and inherited, all load-bearing for whether these labels say +anything true on real data: + +- **That `functionIndex` is the cubin's `.symtab` index** (Tasks 4, 8b; Task + 6 measures it). If it is not, `gpu_src_status` will read `unmapped` on + everything — visibly wrong, which is the point of the enum, but wrong. +- **That `pcOffset` is function-relative in the sense the line table is.** + The design calls this Task 9's half of the question. It is not testable + here: the fixtures' line tables are function-relative by construction (see + `internal/cubin`'s sequence-window reader), and whether CUPTI's + `pcOffset` is measured from the same origin has never been observed. A + mismatch would show as `resolved` statuses pointing at the wrong line — + the one failure in this set that is **not** self-announcing, because a + wrong line looks exactly like a right one. Confirming it needs a cubin + whose SASS-to-source mapping is known independently, sampled on hardware. +- **That real Tier B samples arrive with a usable `cubin_crc`** (Task 8b). If + they arrive zero, every sample reads `no-module`. +- **Whether `KernelName` and the cubin's `.symtab` spelling agree for C++ + kernels** (Task 8b). Unrelated to these labels' correctness but decides + whether any PC sample reaches an execution at all on a real workload. + +The reserved-name clearing in §4 is verified only against tags a test set. It +is a fixed list (`pcSampleReservedLabels`); a label added to the projection +and forgotten there is a name a producer can forge again, and nothing but +review catches that. diff --git a/cmd/gpu-cuda-profile/main.go b/cmd/gpu-cuda-profile/main.go index b4c692c..14ae1c4 100644 --- a/cmd/gpu-cuda-profile/main.go +++ b/cmd/gpu-cuda-profile/main.go @@ -155,7 +155,11 @@ func main() { c.Flush() snap := timeline.Snapshot() - samples := gpu.ProjectExecutions(snap) + // ProjectExecutionsWith rather than ProjectExecutions so the projection's + // own losses reach the operator: gpu_pc labels dropped at the cardinality + // ceiling are invisible in the profile itself, and JoinHealthWith below is + // the only place they are reported. + samples, projStats := gpu.ProjectExecutionsWith(snap, gpu.ProjectionConfig{}) if len(samples) == 0 { log.Fatal("no samples projected; the pipeline produced nothing") } @@ -185,8 +189,8 @@ func main() { // attribution: what the timeline could join it to, and what it evicted // trying. A run can be perfect on the first and quietly useless on the // second, so both are printed - one line when the join is clean, one - // extra line per anomaly when it is not (see gpu.JoinHealth). - for _, line := range gpu.JoinHealth(snap) { + // extra line per anomaly when it is not (see gpu.JoinHealthWith). + for _, line := range gpu.JoinHealthWith(snap, projStats) { log.Print(line) } } diff --git a/cmd/gpu-stub-profile/main.go b/cmd/gpu-stub-profile/main.go index 2233ab1..eafd228 100644 --- a/cmd/gpu-stub-profile/main.go +++ b/cmd/gpu-stub-profile/main.go @@ -114,7 +114,11 @@ func main() { c.Flush() snap := timeline.Snapshot() - samples := gpu.ProjectExecutions(snap) + // ProjectExecutionsWith rather than ProjectExecutions so the projection's + // own losses reach the operator: gpu_pc labels dropped at the cardinality + // ceiling are invisible in the profile itself, and JoinHealthWith below is + // the only place they are reported. + samples, projStats := gpu.ProjectExecutionsWith(snap, gpu.ProjectionConfig{}) if len(samples) == 0 { log.Fatal("no samples projected; the pipeline produced nothing") } @@ -142,8 +146,8 @@ func main() { // attribution: what the timeline could join it to, and what it evicted // trying. A run can be perfect on the first and quietly useless on the // second, so both are printed - one line when the join is clean, one - // extra line per anomaly when it is not (see gpu.JoinHealth). - for _, line := range gpu.JoinHealth(snap) { + // extra line per anomaly when it is not (see gpu.JoinHealthWith). + for _, line := range gpu.JoinHealthWith(snap, projStats) { log.Print(line) } } diff --git a/gpu/joinhealth.go b/gpu/joinhealth.go index 5545524..b1866bb 100644 --- a/gpu/joinhealth.go +++ b/gpu/joinhealth.go @@ -60,7 +60,22 @@ func plural(n uint64, one, many string) string { // at the end, so the two coincide there; a caller that snapshots // periodically should read the eviction counters as running totals. func JoinHealth(snap Snapshot) []string { - anomalies := joinAnomalies(snap) + return JoinHealthWith(snap, ProjectionStats{}) +} + +// JoinHealthWith is JoinHealth plus what the projection itself dropped. +// +// The split exists because ProjectionStats is not in the Snapshot and cannot +// be: it is produced by ProjectExecutionsWith, which runs after the snapshot +// is taken. It is the same shape as SinkStats, which Timeline also cannot see +// and which a caller supplies through CountingSink.SnapshotWith. +// +// The zero value is the "nothing was suppressed" reading, which is also what +// JoinHealth passes, so a caller that does not project through +// ProjectExecutionsWith reports no suppression - correctly, since without that +// call nothing suppressed anything. +func JoinHealthWith(snap Snapshot, proj ProjectionStats) []string { + anomalies := joinAnomalies(snap, proj) return append([]string{joinSummary(snap, len(anomalies))}, anomalies...) } @@ -153,7 +168,7 @@ func joinSummary(snap Snapshot, anomalies int) string { // - it is in the summary line, where it is readable, but raising it would // fire on every healthy periodic snapshot and devalue the word "anomaly" // for the counters that do mean something is wrong. -func joinAnomalies(snap Snapshot) []string { +func joinAnomalies(snap Snapshot, proj ProjectionStats) []string { js, lc, dr := snap.JoinStats, snap.LaunchCache, snap.Dropped execs := uint64(len(snap.Executions)) @@ -301,6 +316,20 @@ func joinAnomalies(snap Snapshot) []string { add("%s evicted before this snapshot — kernels from evicted modules resolve to bare addresses", plural(dr.EvictedModules, "module", "modules")) } + // Raised because the alternative is invisible. A profile whose gpu_pc + // labels were suppressed looks exactly like a profile that never had any: + // the samples are all there, carrying their weight, their stall reason and + // their source line, and nothing in the pprof output says an instruction + // offset was ever meant to be on them. This is the only place that fact is + // reported at all, which is why it is an anomaly rather than a summary + // clause - see ProjectionStats.PCLabelsSuppressed. + if proj.PCLabelsSuppressed > 0 { + add("%s projected without gpu_pc — this profile reached its ceiling of %d distinct "+ + "instruction offsets, so those samples keep their stall reason and source line but "+ + "carry no offset; the workload is loading far more distinct hot code than the "+ + "cardinality budget expects, or ProjectionConfig.MaxDistinctPCLabels is set too low", + plural(proj.PCLabelsSuppressed, "PC sample", "PC samples"), proj.PCLabelCap) + } for _, k := range []struct { one, many string diff --git a/gpu/projection.go b/gpu/projection.go index ebe003c..d178b3e 100644 --- a/gpu/projection.go +++ b/gpu/projection.go @@ -4,6 +4,7 @@ import ( "fmt" "maps" "math/bits" + "path" "strconv" pp "github.com/dpsoft/perf-agent/pprof" @@ -56,7 +57,64 @@ const ( // already refuses for heuristic joins, which are labelled rather than // silently promoted to exact. The period rides along as a label so a // consumer that wants the extrapolation computes it deliberately. +// +// # The PC-sample label set +// +// Every sample projected from a PC sample carries, on top of the execution's +// shared labels: +// +// gpu_stall the instruction's stall reason, when the producer named one +// gpu_pc the instruction's offset within its module, subject to the +// cardinality budget - see pcLabelBudget +// gpu_pc_attrib how the sample reached this execution - unconditional +// gpu_src_status why the sample does or does not have a source location - +// unconditional +// gpu_src_file the source file's BASENAME, only under "resolved" +// gpu_src_line the source line, only under "resolved" +// gpu_src_func the device function, only under "resolved" +// +// The two unconditional ones are unconditional for gpu_join's reason: an +// absent label must never be readable as a positive answer by a consumer who +// does not know to check for its absence. +// +// One label the design lists is deliberately NOT here: gpu_serialized, whose +// three values come from the Tier A sampling windows that do not exist in this +// tree yet. A gpu_serialized="false" that means "we have no windows to check" +// is precisely the answer the design forbids, so until the windows arrive the +// label is absent rather than meaningless. func ProjectExecutions(snap Snapshot) []pp.ProfileSample { + samples, _ := ProjectExecutionsWith(snap, ProjectionConfig{}) + return samples +} + +// ProjectExecutionsWith is ProjectExecutions with a source resolver and a +// cardinality budget, returning the projection's own counters beside the +// samples. +// +// The split mirrors CountingSink.Snapshot / SnapshotWith: the plain form stays +// the whole API for a caller with no module store and no interest in the +// counters, and this form is what a driver calls when it has either. The stats +// are RETURNED rather than accumulated on a field somewhere, because they +// describe one projection of one snapshot - a second call over the same +// snapshot suppresses the same labels again, and a running total of that would +// count the same loss twice. +// +// See ProjectionConfig for why a nil Modules is a supported, accounted-for +// state rather than a skipped one. +func ProjectExecutionsWith(snap Snapshot, cfg ProjectionConfig) ([]pp.ProfileSample, ProjectionStats) { + // A nil Modules is answered by an EMPTY STORE, not by this function + // deciding a status for itself. ModuleStore is the single place + // gpu_src_status is decided (see SrcStatus), and an empty store answers + // every Resolve with no-module - which is exactly the truth when no store + // was configured: no usable module bytes exist for that CRC. Synthesizing + // the same answer here would put a second decision site in the codebase + // and would be the first place a fifth value could ever appear. + modules := cfg.Modules + if modules == nil { + modules = NewModuleStore(ModuleStoreConfig{}) + } + budget := newPCLabelBudget(cfg.MaxDistinctPCLabels) + samples := make([]pp.ProfileSample, 0, len(snap.Executions)) for _, view := range snap.Executions { frames := projectionFrames(view) @@ -75,16 +133,32 @@ func ProjectExecutions(snap Snapshot) []pp.ProfileSample { continue } + // One value per execution, read once: every PC sample on this view + // reached it the same way, so resolving it per sample would be the + // same answer computed len(PCSamples) times. + attrib := projectionPCAttrib(view) + weights := distributeExecutionWeight(executionWeight(view.Exec), view.PCSamples) for i, pcs := range view.PCSamples { // common is projectionLabels' return value, which always starts // from `make(map[string]string)` - never nil - so maps.Clone(common) // is never nil either; no separate nil-guard is needed here. + // + // Every label set from here down is set AFTER this clone, which is + // itself taken after projectionLabels copied the producer-supplied + // Tags. That ordering is the whole reserved-name defence: a launch + // tagged "gpu_src_file" or "gpu_pc_attrib" is overwritten by the + // value this package derived, never the other way round. See + // projectionLabels' note, and TestProjectionReservedLabelsWinOverTags. labels := maps.Clone(common) if pcs.StallReason != "" { labels["gpu_stall"] = pcs.StallReason } - labels["gpu_pc"] = fmt.Sprintf("%#x", pcs.PCOffset) + if budget.admit(pcs.PCOffset) { + labels["gpu_pc"] = fmt.Sprintf("%#x", pcs.PCOffset) + } + labels["gpu_pc_attrib"] = attrib + setSourceLabels(labels, modules.Resolve(pcs.Module.CRC, pcs.FunctionIndex, pcs.PCOffset)) samples = append(samples, pp.ProfileSample{ Pid: pid, @@ -96,7 +170,229 @@ func ProjectExecutions(snap Snapshot) []pp.ProfileSample { }) } } - return samples + return samples, budget.stats() +} + +// ProjectionConfig configures ProjectExecutionsWith. +type ProjectionConfig struct { + // Modules is the store the source labels are resolved against. + // + // Nil is supported and is ACCOUNTED FOR rather than skipped: with no + // store, every PC sample carries gpu_src_status="no-module" and no + // location, which is the same fact for the reader as a cubin that never + // reached the agent (see SrcNoModule). The labels do not disappear, + // because a profile whose source labels are absent is indistinguishable + // from one taken before this phase existed, while a profile that says + // "no-module" on every sample points straight at the missing store. + // This is the same accounting Timeline's module join uses for a nil + // store - see PCJoinStats.GroupsUnresolvedName. + Modules *ModuleStore + + // MaxDistinctPCLabels caps how many DISTINCT gpu_pc values one + // projection may emit. Zero (and anything negative) means + // defaultMaxDistinctPCLabels. See pcLabelBudget for what happens past + // the cap and why gpu_pc is the label that gives way. + MaxDistinctPCLabels int +} + +// ProjectionStats is what one ProjectExecutionsWith call did to the labels it +// was asked to emit. It is per-call, not cumulative - see ProjectExecutionsWith. +type ProjectionStats struct { + // DistinctPCLabels is how many distinct gpu_pc values this projection + // emitted, and PCLabelCap the ceiling it was allowed. Both are reported + // so that "we were nowhere near the cap" and "we sat exactly on it" are + // distinguishable without recomputing anything from the profile. + DistinctPCLabels uint64 `json:"distinct_pc_labels,omitempty"` + PCLabelCap uint64 `json:"pc_label_cap,omitempty"` + + // PCLabelsSuppressed is the design's ProjectionPCLabelsSuppressed: PC + // samples that were projected WITHOUT a gpu_pc label because admitting + // their offset would have pushed the profile past PCLabelCap distinct + // values. Every one of those samples still carries its gpu_stall, + // gpu_pc_attrib and gpu_src_* labels and still carries its full share of + // the execution's duration - only the instruction offset is missing. + // + // Zero is the ordinary reading. Non-zero is surfaced by JoinHealthWith, + // because a profile that silently lost its PC labels looks exactly like + // one that never had any. + PCLabelsSuppressed uint64 `json:"projection_pc_labels_suppressed,omitempty"` +} + +// defaultMaxDistinctPCLabels is the ceiling on distinct gpu_pc values in one +// projection. +// +// It is REASONED, NOT MEASURED, and the design says so: the real distinct-PC +// count on a genuine profile is one of the things deferred to hardware. The +// number comes from the design's own pathological estimate - 20,000 distinct +// PCs costing roughly 400 KB of string table before gzip and ~140 KB after, +// which it calls tolerable. Setting the ceiling at the top of the range the +// design already accepted means it cannot fire on any workload that design +// considered reasonable, and fires only past it. Once the count is measured on +// real cubins this becomes a number rather than a bound on an estimate. +// +// gpu_pc saturates - once every hot instruction has been sampled at least +// once, a longer run adds no new values - so this bounds the profile's string +// table, not its length. +const defaultMaxDistinctPCLabels = 20_000 + +// pcLabelBudget bounds the distinct gpu_pc values one projection emits. +// +// # Why gpu_pc is the label that gives way +// +// gpu_pc is the most numerous label in the set (one value per distinct sampled +// instruction) and the least actionable on its own: a bare instruction offset +// tells a reader nothing that gpu_stall (what the instruction was waiting for) +// and gpu_src_file/_line/_func (where it is in their source) do not tell them +// better. So under cardinality pressure the numerous label is dropped and the +// useful ones are kept, and the drop is counted rather than silent. +// +// # Why an already-seen offset is always admitted +// +// The cap exists to bound the pprof STRING TABLE, which stores one entry per +// distinct label value. A repeat of an offset already emitted costs nothing +// there, so refusing it would suppress information that has already been paid +// for while leaving the bound exactly where it was. The rule is therefore +// "admit no NEW value past the cap", not "emit nothing past the cap": distinct +// values are bounded at exactly the ceiling either way, and the second reading +// would throw away strictly more of the profile for no saving. Suppression is +// counted per SAMPLE, not per distinct offset, because the sample is the thing +// that went out incomplete. +type pcLabelBudget struct { + seen map[uint64]struct{} + ceiling int + suppressed uint64 +} + +func newPCLabelBudget(max int) *pcLabelBudget { + if max <= 0 { + max = defaultMaxDistinctPCLabels + } + return &pcLabelBudget{seen: make(map[uint64]struct{}), ceiling: max} +} + +// admit reports whether this PC sample may carry a gpu_pc label, counting the +// refusal when it may not. %#x is injective over uint64, so the set of +// admitted offsets is exactly the set of distinct label values. +func (b *pcLabelBudget) admit(pcOffset uint64) bool { + if _, ok := b.seen[pcOffset]; ok { + return true + } + if len(b.seen) >= b.ceiling { + b.suppressed++ + return false + } + b.seen[pcOffset] = struct{}{} + return true +} + +func (b *pcLabelBudget) stats() ProjectionStats { + return ProjectionStats{ + DistinctPCLabels: uint64(len(b.seen)), + PCLabelCap: uint64(b.ceiling), //nolint:gosec // newPCLabelBudget forces a positive ceiling. + PCLabelsSuppressed: b.suppressed, + } +} + +// projectionPCAttrib renders gpu_pc_attrib for an execution that carries PC +// samples: HOW those samples reached this execution, and therefore how far the +// attribution can be trusted. One of PCAttribs() - exact, kernel, +// kernel-ambiguous or kernel-multidevice - decided entirely by the join (see +// PCAttrib) and only rendered here. +// +// It is emitted UNCONDITIONALLY on every PC-derived sample, for gpu_join's +// reason: an absent label must never be readable as "exact" by a consumer that +// does not know to check for its absence. Absence is the answer this label can +// least afford, because the value it would be mistaken for is the only one of +// the four that is not an inference. +// +// A view carrying PC samples and no attribution is a bug in the join - the +// conformance suite's assertPCAttribAccompaniesSamples exists to catch it - +// and it renders as a value no consumer can read as one of the four, in the +// same shape and for the same reason as SrcStatus.String's "unset-src-status". +// It is not a fifth value of the label's domain; it is what a join bug looks +// like from the outside. Omitting the label instead would hide the bug behind +// the one reading that must never be reachable by accident. +func projectionPCAttrib(view ExecutionView) string { + if pcAttribRank(view.PCAttrib) == 0 { + return "unset-pc-attrib" + } + return string(view.PCAttrib) +} + +// pcSampleReservedLabels is every label name ProjectExecutionsWith derives per +// PC sample. It is the list projectionLabels clears from the producer-supplied +// Tags; a name added to the projection and forgotten here is a name a producer +// can forge whenever the projection has no value for it. +var pcSampleReservedLabels = []string{ + "gpu_stall", + "gpu_pc", + "gpu_pc_attrib", + "gpu_src_status", + "gpu_src_file", + "gpu_src_line", + "gpu_src_func", +} + +// setSourceLabels writes gpu_src_status and, only under a resolved status, the +// source location. +// +// gpu_src_status is unconditional. An ABSENT source label reads as "not +// sampled"; an explicit status reads as "sampled, and here is why there is no +// location", and those are different facts needing different actions from the +// reader - recompile with -lineinfo, ship the cubin to the agent, or accept +// that the compiler emitted no line for this instruction. The four values are +// decided by ModuleStore and nowhere else; this function only spells them. +// +// The location is taken through Resolution.Source, whose ok comes back in the +// same expression as the data, so a location can only be emitted under +// SrcResolved. There is no branch here that could pair a file with +// "no-lineinfo". +func setSourceLabels(labels map[string]string, res Resolution) { + labels["gpu_src_status"] = res.Status().String() + + fn, file, line, ok := res.Source() + if !ok { + return + } + // An empty string is not a value: a label present and blank reads as "the + // name is blank", while an absent one under gpu_src_status="resolved" + // reads as "the line table had no name for this", which is what happened. + // Neither is expected - the store's resolved path always has both - so + // this is a guard, not a case. + if base := srcFileBase(file); base != "" { + labels["gpu_src_file"] = base + } + if fn != "" { + labels["gpu_src_func"] = fn + } + labels["gpu_src_line"] = strconv.FormatUint(uint64(line), 10) +} + +// srcFileBase reduces a line table's file name to its BASENAME. The directory +// goes nowhere at all. +// +// A cubin's DWARF file names are build-host absolute paths (the fixtures in +// internal/cubin/testdata carry /tmp/perf-agent-cubin-fixtures/single.cu). +// Three reasons not to carry them: they vary per build, so the same kernel +// built twice produces two distinct label values where the reader sees one +// file; they cost a long string in the pprof string table for information no +// reader acts on; and they leak the build environment's layout into a profile +// that may be shared outside the organisation that built it. The basename +// beside gpu_src_func is enough to find the line in the repository it came +// from, which is the only thing a reader does with it. +// +// path.Base, not filepath.Base: the separator in a cubin's line table is the +// build host's, and this must not depend on the agent's own OS. The agent is +// Linux-only and nvcc emits '/' there. +func srcFileBase(file string) string { + if file == "" { + return "" + } + base := path.Base(file) + if base == "." || base == "/" { + return "" + } + return base } // sampledStack returns the launch's captured CPU stack, and whether there is @@ -275,11 +571,35 @@ func labelPID(view ExecutionView) uint32 { // unmatched paths is unambiguous either way. Like every other gpu_* label, // both are set after the Tags copy so a producer-supplied tag can never // forge them. +// +// The same rule covers every label ProjectExecutionsWith layers on top of a +// clone of this map - gpu_stall, gpu_pc, gpu_pc_attrib and the gpu_src_* +// family - because the clone is taken after this function returns and they are +// written after the clone. A tag literally named "gpu_src_file" therefore +// loses to the file the module store resolved, which is the only ordering +// under which a producer-controlled string cannot claim to be a source +// location this package derived. func projectionLabels(view ExecutionView) map[string]string { labels := make(map[string]string) if view.Launch != nil { maps.Copy(labels, view.Launch.Launch.Tags) } + // Every per-PC-sample reserved name is cleared here, immediately after the + // Tags copy and before anything is derived. + // + // Overwriting is not enough on its own, because those labels are + // CONDITIONAL: gpu_src_file is emitted only under a resolved status, + // gpu_stall only when the producer named a reason, gpu_pc only inside the + // cardinality budget, and none of them at all on an execution that carries + // no PC samples. A tag named "gpu_src_file" would therefore survive + // untouched in exactly the cases where this package has no value of its + // own - a forged source location standing beside + // gpu_src_status="no-module", which is the strongest possible form of the + // lie the reserved-name discipline exists to prevent. Reserved names win + // by absence too. + for _, k := range pcSampleReservedLabels { + delete(labels, k) + } if pid := labelPID(view); pid != 0 { labels["gpu_pid"] = strconv.FormatUint(uint64(pid), 10) } diff --git a/gpu/projection_test.go b/gpu/projection_test.go index 54e623d..673da1a 100644 --- a/gpu/projection_test.go +++ b/gpu/projection_test.go @@ -1,7 +1,9 @@ package gpu import ( + "fmt" "slices" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -72,7 +74,15 @@ func TestProjectionReservedLabelsWinOverTags(t *testing.T) { // name this package itself derives from the joined execution/PC sample. // Reserved gpu_* names must always report the real, profiler-derived // value - a tag must never be able to forge it. - tl := NewTimeline(TimelineConfig{}) + // + // The gpu_src_* family is the sharpest case: those labels name a file and + // a line in the profiled program's own source, which is the single most + // believable thing a profile can say. A tag that could set them would let + // a producer point every stalled instruction at a source line of its + // choosing. See TestProjectionSourceLabelsCannotBeForgedByAbsence for the + // other direction - the cases where this package derives no value at all. + st, idx := projStore(t) + tl := NewTimeline(TimelineConfig{Modules: st}) l := launch("a", 10) l.Launch.Tags = map[string]string{ "gpu_queue": "HIJACKED", @@ -81,6 +91,11 @@ func TestProjectionReservedLabelsWinOverTags(t *testing.T) { "gpu_stall": "HIJACKED", "gpu_pc": "HIJACKED", "gpu_pid": "HIJACKED", + "gpu_pc_attrib": "HIJACKED", + "gpu_src_status": "HIJACKED", + "gpu_src_file": "HIJACKED", + "gpu_src_line": "HIJACKED", + "gpu_src_func": "HIJACKED", } require.NoError(t, tl.EmitLaunch(l)) @@ -89,18 +104,24 @@ func TestProjectionReservedLabelsWinOverTags(t *testing.T) { require.NoError(t, tl.EmitExec(exec)) require.NoError(t, tl.EmitPCSample(GPUPCSample{ Correlation: CorrelationID{Backend: BackendCUPTI, Value: "a"}, - PCOffset: 0x1a40, StallReason: "long_scoreboard", Count: 1, TimeNs: 25, + Module: ModuleRef{Backend: BackendCUPTI, CRC: projCRC}, FunctionIndex: idx, + PCOffset: 0x10, StallReason: "long_scoreboard", Count: 1, TimeNs: 25, })) - samples := ProjectExecutions(tl.Snapshot()) + samples, _ := ProjectExecutionsWith(tl.Snapshot(), ProjectionConfig{Modules: st}) require.Len(t, samples, 1) assert.Equal(t, "q1", samples[0].Labels["gpu_queue"], "a tag named gpu_queue must not override the real queue") assert.Equal(t, "dev1", samples[0].Labels["gpu_device"], "a tag named gpu_device must not override the real device") assert.Equal(t, "cupti:a", samples[0].Labels["gpu_correlation"], "a tag named gpu_correlation must not override the real correlation") assert.Equal(t, "long_scoreboard", samples[0].Labels["gpu_stall"], "a tag named gpu_stall must not override the real stall reason") - assert.Equal(t, "0x1a40", samples[0].Labels["gpu_pc"], "a tag named gpu_pc must not override the real pc") + assert.Equal(t, "0x10", samples[0].Labels["gpu_pc"], "a tag named gpu_pc must not override the real pc") assert.Equal(t, "1", samples[0].Labels["gpu_pid"], "a tag named gpu_pid must not override the real process") + assert.Equal(t, "exact", samples[0].Labels["gpu_pc_attrib"], "a tag named gpu_pc_attrib must not override how the sample was joined") + assert.Equal(t, "resolved", samples[0].Labels["gpu_src_status"], "a tag named gpu_src_status must not override what the module store decided") + assert.Equal(t, "single.cu", samples[0].Labels["gpu_src_file"], "a tag named gpu_src_file must not override the resolved source file") + assert.Equal(t, "6", samples[0].Labels["gpu_src_line"], "a tag named gpu_src_line must not override the resolved line") + assert.Equal(t, "addOne", samples[0].Labels["gpu_src_func"], "a tag named gpu_src_func must not override the resolved function") } func TestProjectionSetsPidSampleTypeAndAggregationFromLaunch(t *testing.T) { @@ -451,3 +472,474 @@ func TestSampledExecutionWithPCSamplesStaysExactAndAttributed(t *testing.T) { } assert.Equal(t, uint64(100), total, "the split parts still sum to the measured duration, unscaled") } + +// --- Phase 6: the PC-sample label set ------------------------------------- + +// projCRC is the CRC the projection tests store their cubin under. Its value +// is arbitrary; what matters is that the sample and the store agree, exactly +// as cubin_crc makes them agree on the wire. +const projCRC = 0xABCDEF + +// projStore holds one real -lineinfo cubin and returns the symbol index its +// kernel occupies. The index is read out of the fixture rather than +// hard-coded, for the reason modulestore_test.go's symIndexOf gives: whether +// CUPTI's functionIndex IS the .symtab index is measured on hardware, and +// these tests assert only that the projection reports whatever the store +// resolved. +func projStore(t *testing.T) (*ModuleStore, uint32) { + t.Helper() + b := fixture(t, "single_lineinfo.cubin") + st := NewModuleStore(ModuleStoreConfig{Capacity: 8}) + require.NoError(t, st.Put(projCRC, b)) + return st, symIndexOf(t, b, "addOne") +} + +// pcSampleAt is one PC sample against a module, with a stall reason so that +// every test below also carries the label the cap must never touch. +func pcSampleAt(crc uint64, fnIndex uint32, pcOffset uint64) GPUPCSample { + return GPUPCSample{ + Module: ModuleRef{Backend: BackendCUPTI, CRC: crc}, + FunctionIndex: fnIndex, + PCOffset: pcOffset, + StallReason: "long_scoreboard", + Count: 1, + } +} + +// pcView is an execution carrying PC samples, with the attribution the join +// would have decided for it. +func pcView(attrib PCAttrib, pcs ...GPUPCSample) ExecutionView { + return ExecutionView{ + Exec: GPUKernelExec{StartNs: 0, EndNs: 100, KernelName: "addOne"}, + PCSamples: pcs, + PCAttrib: attrib, + } +} + +// TestProjectionEmitsAllFourSrcStatuses is the core table: every value of +// gpu_src_status reaches a projected sample, from a real fixture through the +// real store, and the source labels ride only under "resolved". +// +// The unconditional half is the load-bearing one. An ABSENT source label reads +// as "this sample was never source-mapped"; an explicit status reads as +// "sampled, and here is precisely why there is no location" - a build flag, a +// missing cubin, or an instruction the compiler emitted no line for. Those are +// three different actions for the reader, and a status that could go missing +// would collapse them into one shrug. +// +// Mutations this catches: making gpu_src_status conditional on anything; +// emitting gpu_src_file/_line/_func under a status other than resolved; +// renaming any of the four wire spellings. +func TestProjectionEmitsAllFourSrcStatuses(t *testing.T) { + withInfo := fixture(t, "single_lineinfo.cubin") + noInfo := fixture(t, "single_nolineinfo.cubin") + + const ( + crcWithInfo = 0x1111 + crcNoInfo = 0x2222 + crcAbsent = 0x3333 + ) + st := NewModuleStore(ModuleStoreConfig{Capacity: 8}) + require.NoError(t, st.Put(crcWithInfo, withInfo)) + require.NoError(t, st.Put(crcNoInfo, noInfo)) + idx := symIndexOf(t, withInfo, "addOne") + noInfoIdx := symIndexOf(t, noInfo, "addOne") + + cases := []struct { + name string + sample GPUPCSample + want SrcStatus + }{ + {"line table covers this pc", pcSampleAt(crcWithInfo, idx, 0x10), SrcResolved}, + {"module built without -lineinfo", pcSampleAt(crcNoInfo, noInfoIdx, 0x10), SrcNoLineInfo}, + {"cubin never reached the agent", pcSampleAt(crcAbsent, idx, 0x10), SrcNoModule}, + {"pc past the end of the function", pcSampleAt(crcWithInfo, idx, 0x180), SrcUnmapped}, + } + + views := make([]ExecutionView, 0, len(cases)) + for _, tc := range cases { + views = append(views, pcView(PCAttribKernel, tc.sample)) + } + samples, _ := ProjectExecutionsWith(Snapshot{Executions: views}, ProjectionConfig{Modules: st}) + require.Len(t, samples, len(cases)) + + seen := make(map[SrcStatus]bool) + for i, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + labels := samples[i].Labels + require.Contains(t, labels, "gpu_src_status", + "gpu_src_status is unconditional: an absent label reads as 'not sampled', which is a different fact") + assert.Equal(t, tc.want.String(), labels["gpu_src_status"]) + + if tc.want != SrcResolved { + assert.NotContains(t, labels, "gpu_src_file", + "a location may only ride under a resolved status") + assert.NotContains(t, labels, "gpu_src_line") + assert.NotContains(t, labels, "gpu_src_func") + return + } + assert.Equal(t, "single.cu", labels["gpu_src_file"]) + assert.Equal(t, "6", labels["gpu_src_line"]) + assert.Equal(t, "addOne", labels["gpu_src_func"]) + }) + seen[tc.want] = true + } + + for _, s := range SrcStatuses() { + assert.True(t, seen[s], "gpu_src_status %s is not reachable from this table", s) + } +} + +// TestProjectionWithoutAModuleStoreStillAnswersEveryPCSample pins the nil +// case. With no store nothing can be resolved, and the honest answer is +// "no-module" on every sample - the same fact as a cubin that never arrived. +// +// The failure this prevents is the quiet one: if the labels simply vanished +// when no store was configured, a profile taken with the store unwired would +// be byte-identical to one taken before this phase existed, and nothing would +// point at the missing store. "no-module" on every sample points straight at +// it. +func TestProjectionWithoutAModuleStoreStillAnswersEveryPCSample(t *testing.T) { + snap := Snapshot{Executions: []ExecutionView{ + pcView(PCAttribKernel, pcSampleAt(projCRC, 7, 0x10), pcSampleAt(projCRC, 7, 0x20)), + }} + + samples, _ := ProjectExecutionsWith(snap, ProjectionConfig{}) + require.Len(t, samples, 2) + for _, s := range samples { + assert.Equal(t, "no-module", s.Labels["gpu_src_status"], + "no store means no usable module bytes, which is exactly what no-module says") + assert.NotContains(t, s.Labels, "gpu_src_file") + } + + // And the plain entry point behaves identically - it is the same call. + plain := ProjectExecutions(snap) + require.Len(t, plain, 2) + assert.Equal(t, "no-module", plain[0].Labels["gpu_src_status"]) +} + +// TestProjectionSrcFileIsABasenameNotABuildHostPath pins the directory +// decision. The fixture's line table carries an absolute build-host path +// (/tmp/perf-agent-cubin-fixtures/single.cu), so this test fails the moment +// the projection passes the file through unchanged. +// +// Three reasons the directory goes nowhere, all of which this asserts the +// consequence of: the path varies per build, so the same kernel built twice +// would produce two label values for one file; it is a long string in the +// pprof string table that no reader acts on; and it leaks the build +// environment's layout into a profile that may be shared. No OTHER label may +// smuggle it back in either, which is what the second loop checks. +func TestProjectionSrcFileIsABasenameNotABuildHostPath(t *testing.T) { + st, idx := projStore(t) + + samples, _ := ProjectExecutionsWith( + Snapshot{Executions: []ExecutionView{pcView(PCAttribKernel, pcSampleAt(projCRC, idx, 0x10))}}, + ProjectionConfig{Modules: st}) + require.Len(t, samples, 1) + + assert.Equal(t, "single.cu", samples[0].Labels["gpu_src_file"]) + for k, v := range samples[0].Labels { + assert.NotContains(t, v, "/", "no label may carry a directory: %s=%q", k, v) + assert.NotContains(t, v, "perf-agent-cubin-fixtures", + "no label may leak the build host's layout: %s=%q", k, v) + } +} + +// TestSrcFileBaseRejectsWhatIsNotAName covers srcFileBase directly, including +// the inputs a line table should never produce but which must not turn into a +// label value of "." or "/" if it ever does. +func TestSrcFileBaseRejectsWhatIsNotAName(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {"/home/build/src/kernels/matmul.cu", "matmul.cu"}, + {"kernels/matmul.cu", "matmul.cu"}, + {"matmul.cu", "matmul.cu"}, + {"", ""}, + {".", ""}, + {"/", ""}, + } { + assert.Equal(t, tc.want, srcFileBase(tc.in), "srcFileBase(%q)", tc.in) + } +} + +// TestProjectionEmitsAllFourPCAttribValues walks the enum itself rather than a +// hand-copied list, so a fifth value added to PCAttribs() without a projection +// case fails here rather than shipping as a label nobody rendered. +func TestProjectionEmitsAllFourPCAttribValues(t *testing.T) { + attribs := PCAttribs() + require.Len(t, attribs, 4) + + views := make([]ExecutionView, 0, len(attribs)) + for _, a := range attribs { + views = append(views, pcView(a, pcSampleAt(projCRC, 7, 0x10))) + } + samples, _ := ProjectExecutionsWith(Snapshot{Executions: views}, ProjectionConfig{}) + require.Len(t, samples, len(attribs)) + + for i, a := range attribs { + assert.Equal(t, string(a), samples[i].Labels["gpu_pc_attrib"], + "gpu_pc_attrib must report exactly what the join decided") + } + assert.Equal(t, "exact", samples[0].Labels["gpu_pc_attrib"], + "the one value that is not an inference must be spelled exactly this way") +} + +// TestProjectionPCAttribIsNeverSilentlyAbsent is the honesty half of the +// label. A view carrying PC samples with no attribution is a join bug; the +// projection must make that visible rather than omit the label, because an +// absent gpu_pc_attrib is readable as "exact" - the only one of the four that +// claims vendor-provided truth - by a consumer who does not know to check. +// +// Mutation this catches: `if view.PCAttrib != "" { labels[...] = ... }`. +func TestProjectionPCAttribIsNeverSilentlyAbsent(t *testing.T) { + samples, _ := ProjectExecutionsWith( + Snapshot{Executions: []ExecutionView{pcView("", pcSampleAt(projCRC, 7, 0x10))}}, + ProjectionConfig{}) + require.Len(t, samples, 1) + + got, ok := samples[0].Labels["gpu_pc_attrib"] + require.True(t, ok, "a PC sample with no attribution must still carry the label") + for _, a := range PCAttribs() { + assert.NotEqual(t, string(a), got, + "a join bug must not render as one of the four real values, least of all %s", PCAttribExact) + } +} + +// TestProjectionKernelAmbiguousNeverCoincidesWithGpuAmbiguous is the +// de-overloading assertion, driven end to end through the real join rather +// than a hand-built view. +// +// Two executions of one kernel are in the horizon, so which invocation the +// samples came from is an inference. It is marked in gpu_pc_attrib, and +// gpu_ambiguous - which means "the heuristic LAUNCH join chose between +// candidate launches" and feeds AmbiguousHeuristicMatchCount - stays absent. +// Emitting both on one sample would put two unrelated facts on one flag. +func TestProjectionKernelAmbiguousNeverCoincidesWithGpuAmbiguous(t *testing.T) { + const pid = 4242 + f := newPCJoinFixture(t, TimelineConfig{}) + + f.sample(t, pid, 10) + f.sample(t, pid, 11) + require.NoError(t, f.tl.EmitExec(pcExec(pid, f.kernel, "0", 20, 30))) + require.NoError(t, f.tl.EmitExec(pcExec(pid, f.kernel, "0", 40, 50))) + + snap := f.tl.Snapshot() + samples, _ := ProjectExecutionsWith(snap, ProjectionConfig{Modules: f.store}) + + var ambiguous int + for _, s := range samples { + if s.Labels["gpu_pc_attrib"] != string(PCAttribKernelAmbiguous) { + continue + } + ambiguous++ + assert.NotContains(t, s.Labels, "gpu_ambiguous", + "PC ambiguity and heuristic-launch ambiguity are different joins with different failure "+ + "modes; one flag cannot carry both") + } + assert.Equal(t, 2, ambiguous, "both samples of the ambiguous group must carry the mark") + assert.Zero(t, snap.JoinStats.AmbiguousHeuristicMatchCount, + "no launch was joined heuristically here, so that counter must not have moved") +} + +// TestProjectionCapSuppressesGpuPCAndOnlyGpuPC is the cardinality budget. +// +// Five distinct instruction offsets against a ceiling of two: the first two +// distinct values are emitted, the remaining three samples go out without an +// offset, and ProjectionPCLabelsSuppressed reads exactly three. Every one of +// the five keeps its stall reason, its attribution, its source status and its +// full share of the execution's duration - the label that gives way under +// pressure is the numerous one, not the actionable ones. +// +// Mutations this catches: capping the wrong label; dropping the sample instead +// of the label (the weights would no longer sum to the duration); counting +// distinct suppressed offsets rather than suppressed samples. +func TestProjectionCapSuppressesGpuPCAndOnlyGpuPC(t *testing.T) { + st, idx := projStore(t) + pcs := []GPUPCSample{ + pcSampleAt(projCRC, idx, 0x10), + pcSampleAt(projCRC, idx, 0x20), + pcSampleAt(projCRC, idx, 0x30), + pcSampleAt(projCRC, idx, 0x40), + pcSampleAt(projCRC, idx, 0x50), + } + snap := Snapshot{Executions: []ExecutionView{pcView(PCAttribKernel, pcs...)}} + + samples, stats := ProjectExecutionsWith(snap, ProjectionConfig{Modules: st, MaxDistinctPCLabels: 2}) + require.Len(t, samples, 5) + + assert.Equal(t, uint64(3), stats.PCLabelsSuppressed, + "three samples past a ceiling of two distinct offsets") + assert.Equal(t, uint64(2), stats.DistinctPCLabels) + assert.Equal(t, uint64(2), stats.PCLabelCap) + + var total uint64 + for i, s := range samples { + total += s.Value + if i < 2 { + assert.Equal(t, fmt.Sprintf("%#x", pcs[i].PCOffset), s.Labels["gpu_pc"], + "the first distinct offsets inside the budget are emitted") + } else { + assert.NotContains(t, s.Labels, "gpu_pc", "sample %d is past the ceiling", i) + } + assert.Equal(t, "long_scoreboard", s.Labels["gpu_stall"], "the cap must not touch gpu_stall") + assert.Equal(t, "kernel", s.Labels["gpu_pc_attrib"], "the cap must not touch gpu_pc_attrib") + assert.Equal(t, "resolved", s.Labels["gpu_src_status"], "the cap must not touch gpu_src_status") + assert.Equal(t, "single.cu", s.Labels["gpu_src_file"], "the cap must not touch the source location") + assert.Equal(t, "addOne", s.Labels["gpu_src_func"]) + assert.Contains(t, s.Labels, "gpu_src_line") + } + assert.Equal(t, uint64(100), total, + "a suppressed label must not cost the sample its weight; the parts still sum to the duration") +} + +// TestProjectionCapReadmitsAnOffsetItAlreadyEmitted pins the rule that the cap +// bounds DISTINCT values, not samples. A repeat of an offset already in the +// profile costs the pprof string table nothing, so refusing it would drop +// information already paid for while leaving the bound exactly where it was. +func TestProjectionCapReadmitsAnOffsetItAlreadyEmitted(t *testing.T) { + offsets := []uint64{0x10, 0x20, 0x10, 0x30} + pcs := make([]GPUPCSample, 0, len(offsets)) + for _, off := range offsets { + pcs = append(pcs, pcSampleAt(projCRC, 7, off)) + } + + samples, stats := ProjectExecutionsWith( + Snapshot{Executions: []ExecutionView{pcView(PCAttribKernel, pcs...)}}, + ProjectionConfig{MaxDistinctPCLabels: 2}) + require.Len(t, samples, 4) + + assert.Equal(t, "0x10", samples[2].Labels["gpu_pc"], + "an offset already in the string table costs nothing to repeat") + assert.NotContains(t, samples[3].Labels, "gpu_pc", "a third DISTINCT value is what the cap refuses") + assert.Equal(t, uint64(1), stats.PCLabelsSuppressed) + assert.Equal(t, uint64(2), stats.DistinctPCLabels) +} + +// TestProjectionCapIsSurfacedInJoinHealth closes the loop the design demands: +// a profile that silently lost its PC labels looks identical to one that never +// had any, so the suppression has to be readable somewhere outside the +// profile. +func TestProjectionCapIsSurfacedInJoinHealth(t *testing.T) { + snap := Snapshot{Executions: []ExecutionView{ + pcView(PCAttribKernel, pcSampleAt(projCRC, 7, 0x10), pcSampleAt(projCRC, 7, 0x20)), + }} + _, stats := ProjectExecutionsWith(snap, ProjectionConfig{MaxDistinctPCLabels: 1}) + require.Equal(t, uint64(1), stats.PCLabelsSuppressed) + + withProj := strings.Join(JoinHealthWith(snap, stats), "\n") + assert.Contains(t, withProj, "without gpu_pc", + "the operator must be told the labels were dropped; nothing in the profile says so") + assert.Contains(t, withProj, "anomal") + + assert.NotContains(t, strings.Join(JoinHealth(snap), "\n"), "without gpu_pc", + "a caller that suppressed nothing must not be told it did") +} + +// TestProjectionAddsNoFrames is the negative assertion the design requires: +// this phase adds labels and NOTHING to stack identity. At PC-sampling rates a +// frame per instruction or per source line destroys aggregation and fragments +// the kernel's own block, so frames stay exhaustively +// -> [gpu:launch] -> [gpu:kernel:]. +// +// Mutation this catches: promoting the PC, the stall reason or the source line +// to a frame, in any spelling. +func TestProjectionAddsNoFrames(t *testing.T) { + st, idx := projStore(t) + view := pcView(PCAttribKernelAmbiguous, pcSampleAt(projCRC, idx, 0x10), pcSampleAt(projCRC, idx, 0x40)) + view.Launch = &GPUKernelLaunch{Launch: LaunchContext{CPUStack: pp.FramesFromNames([]string{"main"})}} + + samples, _ := ProjectExecutionsWith(Snapshot{Executions: []ExecutionView{view}}, + ProjectionConfig{Modules: st}) + require.Len(t, samples, 2) + + // The kernel name is deliberately NOT on this list: [gpu:kernel:] is + // one of the three frames the design fixes, and it is the deepest one. The + // forbidden strings are the per-sample detail this phase adds - the offset, + // the stall reason, the source location and the attribution quality - + // every one of which would fragment that kernel's own block if promoted. + forbidden := []string{ + "gpu:pc", "gpu:src", "long_scoreboard", "single.cu", + "kernel-ambiguous", "resolved", "0x10", "0x40", + } + for _, s := range samples { + names := frameNames(s.Stack) + assert.Equal(t, []string{"main", FrameLaunch, "[gpu:kernel:addOne]"}, names) + for _, name := range names { + for _, bad := range forbidden { + assert.NotContains(t, name, bad, "frame %q must not carry per-sample detail", name) + } + } + } + assert.Equal(t, frameNames(samples[0].Stack), frameNames(samples[1].Stack), + "two PC samples from one kernel must share one stack and differ only by label") +} + +// TestProjectionSourceLabelsCannotBeForgedByAbsence is the second half of the +// reserved-name discipline, and the half the conditional labels made +// necessary. +// +// Overwriting alone is not enough: gpu_src_file is emitted ONLY under a +// resolved status, so a producer tag of that name would survive untouched in +// exactly the cases where this package has no value of its own - a forged +// source location standing beside gpu_src_status="no-module", which is worse +// than any value it could overwrite. The same goes for gpu_pc past the +// cardinality cap, for gpu_stall when the producer named no reason, and for +// every one of these names on an execution that carries no PC samples at all. +// Reserved names win by absence too. +func TestProjectionSourceLabelsCannotBeForgedByAbsence(t *testing.T) { + forged := map[string]string{ + "gpu_src_status": "resolved", + "gpu_src_file": "attacker.cu", + "gpu_src_line": "1", + "gpu_src_func": "attacker_kernel", + "gpu_pc": "0xdeadbeef", + "gpu_stall": "not_stalled", + "gpu_pc_attrib": "exact", + "pod_uid": "pod-a", + } + tagged := func(pcs ...GPUPCSample) ExecutionView { + v := pcView(PCAttribKernel, pcs...) + v.Launch = &GPUKernelLaunch{Launch: LaunchContext{Tags: forged}} + return v + } + + // No module for this CRC, so no location is derived; one PC sample past a + // ceiling of zero distinct offsets... the ceiling cannot be zero (0 means + // "default"), so the second sample is the one the cap refuses. + unresolved := pcSampleAt(0xDEAD, 7, 0x10) + unresolved.StallReason = "" // the producer named none + second := pcSampleAt(0xDEAD, 7, 0x20) + second.StallReason = "" + + snap := Snapshot{Executions: []ExecutionView{ + tagged(unresolved, second), + func() ExecutionView { // an execution with no PC samples at all + v := ExecutionView{Exec: GPUKernelExec{StartNs: 0, EndNs: 10, KernelName: "addOne"}} + v.Launch = &GPUKernelLaunch{Launch: LaunchContext{Tags: forged}} + return v + }(), + }} + + samples, stats := ProjectExecutionsWith(snap, ProjectionConfig{MaxDistinctPCLabels: 1}) + require.Len(t, samples, 3) + require.Equal(t, uint64(1), stats.PCLabelsSuppressed) + + for i, s := range samples { + assert.Equal(t, "pod-a", s.Labels["pod_uid"], "ordinary tags still ride, sample %d", i) + assert.NotContains(t, s.Labels, "gpu_src_file", + "a tag named gpu_src_file must not survive where no location was resolved (sample %d)", i) + assert.NotContains(t, s.Labels, "gpu_src_line") + assert.NotContains(t, s.Labels, "gpu_src_func") + assert.NotContains(t, s.Labels, "gpu_stall", + "a tag named gpu_stall must not survive where the producer named no reason (sample %d)", i) + } + assert.Equal(t, "no-module", samples[0].Labels["gpu_src_status"], + "the derived status must overwrite a tag claiming 'resolved'") + assert.Equal(t, "0x10", samples[0].Labels["gpu_pc"]) + assert.NotContains(t, samples[1].Labels, "gpu_pc", + "a tag named gpu_pc must not survive the cardinality cap") + assert.NotContains(t, samples[2].Labels, "gpu_pc", + "an execution with no PC samples has no offset to report, forged or otherwise") + assert.NotContains(t, samples[2].Labels, "gpu_src_status", + "gpu_src_status is unconditional on PC-DERIVED samples; an execution with none has nothing to say") + assert.NotContains(t, samples[2].Labels, "gpu_pc_attrib") +}