From c859b8be74b1bf18e1c2a623591329ee7e35a21a Mon Sep 17 00:00:00 2001 From: diego Date: Tue, 25 Aug 2026 20:49:46 -0300 Subject: [PATCH] =?UTF-8?q?gpu:=20tier=20selection=20=E2=80=94=20one=20set?= =?UTF-8?q?ting,=20three=20values,=20and=20off=20means=20off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PERFAGENT_GPU_PC_SAMPLING becomes off | continuous | serialized (0 | 1 | 2 still accepted, since container specs already carry them), read by both producers through one parser in shim/core/pctier.h and by the agent through gpu/tier.go, and surfaced on cmd/gpu-cuda-profile as --gpu-pc-sampling. TimelineConfig.SerializedSampling is replaced by TimelineConfig.PCSampling rather than joined by it: two fields that can disagree about which tier is running is how a profile ends up disclosing one thing and doing another. The tiers cannot run together, and the reason is what is written down. COLLECTION_MODE is a single per-CUcontext CUPTI attribute, so a process could set different modes on different contexts - but which context a kernel lands on is the application's choice, not the profiler's, so "both" would produce one profile whose attribution quality varied along an axis the operator can neither see nor control. Naming two tiers is therefore a startup error in all three shapes it can take: one value naming both, the flag and the environment naming different ones, and an unknown value in either. Every refusal falls CLOSED to off and explains itself; none of them picks a tier. The value is parsed rather than rejected as syntax on purpose - "both" has to be expressible for the refusal to be reachable, and a parser that took the first token of "continuous,serialized" would be the silent pick the rule forbids. Because Tier A perturbs the workload it measures, "serialized" is refused unless --gpu-pc-sampling-acknowledge-perturbation says so, and joinhealth carries a standing warning for the whole run rather than once at startup - it stands even on a Tier A snapshot in which nothing went wrong, which is exactly the profile whose reader has no other way to learn the tier was on. The warning names all three perturbations, because an operator told only about the first will misread the other two: they will see gpu_serialized="true" on the GPU samples, conclude the marked ones are the perturbed ones, and then trust an off-CPU profile whose synchronization waits are inflated by this very mechanism and carry no marking at all. The third is that Tier A is unavailable where CUDA graphs are in use. A limitation an operator is told about is a limitation; one they discover from a misleading profile is a defect. Off means off, and it is asserted where a uprobe would assert it. shim/stub/pc_tier_test.cc reads its own .note.stapsdt, patches the four PC-sampling probe nops with int3 and counts the traps: with the tier off, and with the stub's own PC knobs turned up, not one of them may fire, while the launch and exec probes must - so an inert producer cannot make it pass. The continuous and serialized passes fire those same sites, so the negative passes are not vacuous. This needed the stub to honour the tier at all; it is now the outer gate over the stub's own knobs, which no existing test sets. One anomaly strengthened while in there: the "unknown" clause was guarded by SamplingWindowsReceived > 0, which suppressed it in the worst available case - Tier A selected, not one window received, every execution unknown. Unknown is unreachable in the other two tiers, so the guard hid the case that most needed raising; it now names which cause applies instead of disappearing. Verified: make -C shim {,test,check-fpless,check-cubin-defer,nvidia}; go build ./... && go vet ./...; go test ./gpu/ ./gpuprobe/ ./internal/... -count=1; go test ./gpu/ ./gpuprobe/ -race -count=4; golangci-lint 0 issues. The off-means- off test was mutation-checked: removing the stub's tier gate makes it fail and name the probe that fired. No CUpti_ path here has run - CapEff: 0, no GPU. --- .superpowers/sdd/task-11-selection-report.md | 303 ++++++++++++++ cmd/gpu-cuda-profile/main.go | 52 ++- gpu/conformance_test.go | 2 +- gpu/joinhealth.go | 71 +++- gpu/joinhealth_test.go | 114 +++++- gpu/serialization_test.go | 6 +- gpu/tier.go | 354 ++++++++++++++++ gpu/tier_test.go | 408 +++++++++++++++++++ gpu/timeline.go | 83 ++-- shim/Makefile | 13 +- shim/core/pctier.h | 146 +++++++ shim/core/pctier_test.cc | 127 ++++++ shim/nvidia/cupti_adapter.cc | 95 +++-- shim/stub/pc_tier_test.cc | 309 ++++++++++++++ shim/stub/stub.cc | 77 +++- 15 files changed, 2062 insertions(+), 98 deletions(-) create mode 100644 .superpowers/sdd/task-11-selection-report.md create mode 100644 gpu/tier.go create mode 100644 gpu/tier_test.go create mode 100644 shim/core/pctier.h create mode 100644 shim/core/pctier_test.cc create mode 100644 shim/stub/pc_tier_test.cc diff --git a/.superpowers/sdd/task-11-selection-report.md b/.superpowers/sdd/task-11-selection-report.md new file mode 100644 index 0000000..504cfc1 --- /dev/null +++ b/.superpowers/sdd/task-11-selection-report.md @@ -0,0 +1,303 @@ +# Task 11 — Tier selection + +Branch `feat/tier-selection`, one commit on top of `feat/tier-a-serialized` (`d3dcb61f`, Task 10 / +PR #89, which is itself one commit on `main`). Rebase onto `main` when #89 merges. + +No ABI change, no BPF change, no `.o` churn. Nothing in Tier A's or Tier B's behaviour, the cubin +capture, the `CubinView` guard or the `MODULE_UNLOAD_STARTING` drain was touched except to be +gated by the setting this task adds. + +--- + +## The setting, and its three values + +**One setting.** `PERFAGENT_GPU_PC_SAMPLING`, read by both producers through one parser +(`shim/core/pctier.h`), written by the agent from one parser (`gpu/tier.go`), and surfaced on +`cmd/gpu-cuda-profile` as `--gpu-pc-sampling`. + +| value | tier | what it does | +| --- | --- | --- | +| `off` (default, and what unset means) | none | no PC-sampling call at all | +| `continuous` | Tier B, `CUPTI_..._CONTINUOUS` | no serialization; PC samples join through the module | +| `serialized` | Tier A, `..._KERNEL_SERIALIZED`, duty-cycled | exact launch attribution; **perturbs the workload** | + +`0` / `1` / `2` are accepted as aliases on both ends and are not legacy debt to be dropped: this +variable has been numeric since Task 6, container specs are already set that way, and a parser +that ignored them would turn a configured Tier A run into a silent off one. The agent **writes** +the name, because `pc_sampling=serialized` in `ps eauxwww` or a pod spec is a value nobody has to +go look up. + +**The zero value is off**, in Go (`PCSamplingOff = iota`) and in C++ (`PCSamplingTier::kOff = 0`). +This is the same discipline `SerializationUnknown` carries and it exists for the same reason: a +config nobody filled in, a struct a test built by hand, a field lost in a copy and a parse that +failed all land on the tier that does not touch the workload. Turning PC sampling **on** has to be +written somewhere, deliberately. + +**Every refusal falls closed to `off` and says so at length. None of them picks a tier.** An +unreadable setting resolved to "the cheaper one" or "the first token" is a decision the operator +did not make and cannot see in the output. + +`TimelineConfig.SerializedSampling bool` is **replaced** by `TimelineConfig.PCSampling +PCSamplingTier` rather than joined by it. One field, not a tier plus a bool: two fields that can +disagree about which tier is running is precisely how a profile ends up disclosing one thing and +doing another. The tier also rides out on `Snapshot.PCSampling`, because "Tier A was asked for and +no window arrived" (everything `"unknown"`) and "Tier A was never asked for" (everything +`"false"`) are different facts and an inference from `SamplingWindowsReceived == 0` gets exactly +that case backwards. + +--- + +## Why exclusivity is process-wide + +`COLLECTION_MODE` is a single **per-`CUcontext`** CUPTI attribute, so a process could in principle +set `KERNEL_SERIALIZED` on one context and `CONTINUOUS` on another. Nothing in CUPTI forbids it. + +What forbids it is that **which context a given kernel lands on is the application's choice, not +the profiler's.** A "both" mode would therefore emit one profile in which some kernels carry exact +launch attribution and inflated durations while others carry inferred attribution and honest ones, +split along an axis the operator can neither see nor control. A profile whose trustworthiness +varies invisibly is worse than either tier alone. + +So the selection is process-wide and exclusive, and **naming two tiers is a startup error**. It is +reachable in three shapes, all refused, all with the reason attached rather than only the rule: + +1. **one value naming two tiers** — `continuous,serialized`, `1+2`, `serialized continuous`. The + value is *parsed* rather than rejected as a syntax error on purpose: "both" has to be + expressible for the refusal to be reachable at all, and a parser that quietly took the first + token of `continuous,serialized` would be the silent pick this rule exists to prevent. Both + orderings are tested, because such a parser answers "continuous" for one and "serialized" for + the other — correct-looking in half the runs, perturbing the workload in the other half. +2. **the flag and the environment naming different tiers.** There genuinely are two sources: a + driver takes a flag, and the producer's environment may already carry the variable from a shell + export or a container spec. There is deliberately **no precedence** between them — resolving it + by one would leave the profile's attribution quality decided by whichever source the operator + forgot about. An *unspecified* flag defers to the environment (that is not a disagreement); an + explicit `--gpu-pc-sampling=off` against an exported `serialized` is one, and is refused. +3. **an unknown value**, in either source, attributed to the source that carried it — an operator + staring at a correct flag needs to be told the environment is what is wrong. + +The producer refuses on its own account too, rather than trusting the agent to have filtered: +`shim/core/pctier.h` returns `kUnknown` / `kNotExclusive`, both adapter and stub log the whole +explanation, and both fall to `kOff`. The adapter counts it in `g_pc_tier_refused` and prints it — +a startup log line in somebody else's process is routinely swallowed by whatever captures its +stderr, and "PC sampling produced nothing" and "PC sampling was refused at startup" must not look +the same in the report. + +**Switching tiers mid-run is out of scope**, gated on Task 10's open hardware question about +whether `COLLECTION_MODE` can change between `Stop` and `Start` without a full `Disable`/`Enable`. +Nothing here depends on the answer; the mode is set once, at context creation. + +--- + +## The acknowledgement gate on `serialized` + +Tier A is a destructive flag in the ordinary sense — it changes the thing being measured — so it +takes the same shape as one. `serialized` is refused unless +`--gpu-pc-sampling-acknowledge-perturbation` is set (`PCSamplingRequest.AcknowledgePerturbation`), +and the refusal names all three perturbations rather than just naming a flag, because that text is +the last thing an operator sees before deciding to pass it: + +> GPU PC-sampling tier "serialized" perturbs the workload and was not acknowledged. Tier A +> serializes GPU kernels in bursts: it inflates the kernel durations this profile reports, it +> distorts any CPU and off-CPU profile taken alongside it with no marking in those profiles at +> all, and it is unavailable where CUDA graphs are in use. Re-run with +> `--gpu-pc-sampling-acknowledge-perturbation` if that is what you want + +An unacknowledged Tier A does **not** run, and does not run as Tier B either — a silent downgrade +would leave the operator reading a Tier B profile believing they asked for Tier A. The +acknowledgement gates Tier A and nothing else: `off` and `continuous` behave identically with and +without it, so an operator who leaves it set in a script has not thereby changed what those do. + +--- + +## The standing warning, verbatim + +`gpu.PCSamplingStandingWarning(tier)` returns these four lines for `PCSamplingSerialized` and +`nil` for the other two tiers. `JoinHealthWith` emits them on **every** render, immediately under +the summary and above the anomalies — a perturbation notice shown once at startup has scrolled +away long before the profile it applies to is read. `cmd/gpu-cuda-profile` also prints them at +startup, for the operator watching the run begin; the standing copy is for the one reading the +profile an hour later, who is the reader the warning is actually for. + +``` +gpu pc sampling WARNING: Tier A ("serialized", CUPTI KERNEL_SERIALIZED) PC sampling was selected for this run — the profiler deliberately perturbs the workload it is measuring. This warning stands for the whole run, not just at startup. It names three distinct perturbations, because an operator told only about the first will misread the other two. +gpu pc sampling WARNING: (1) GPU KERNEL DURATIONS INSIDE A BURST ARE INFLATED by serialization — kernels that would have overlapped ran one at a time. Those executions are marked gpu_serialized="true"; executions that cannot be shown to have run outside every burst are marked "unknown" and must never be read as "false". +gpu pc sampling WARNING: (2) CPU AND OFF-CPU SAMPLES TAKEN DURING A BURST ARE DISTORTED AND CARRY NO MARKING AT ALL. gpu_serialized reaches only the GPU projection (ProjectExecutions); the on-CPU and off-CPU profilers are a separate path with no window awareness, and serialization inflates precisely the synchronization wait that off-CPU profiling exists to measure. cudaDeviceSynchronize-shaped off-CPU time will look worse than it is, with nothing in that profile saying why. +gpu pc sampling WARNING: (3) TIER A IS UNAVAILABLE WHERE CUDA GRAPHS ARE IN USE. A graph launch fires one runtime callback for N kernels, so Tier A's exact-launch attribution would be false while still looking exact; the producer refuses to open bursts in such a process rather than downgrading silently to Tier B, and this profile then carries no Tier A PC samples at all. +``` + +**All three, because an operator told only about the first will misread the other two.** They will +see `gpu_serialized="true"` on the GPU samples, conclude that the marked ones are the perturbed +ones, and then trust an off-CPU profile whose synchronization waits are inflated by exactly this +mechanism and carry no marking at all. A limitation an operator is told about is a limitation; one +they discover from a misleading profile is a defect. + +**It stands even when nothing went wrong.** `TestTheTierAWarningStandsOnAnOtherwisePerfectRun` +renders it on a Tier A snapshot with every join exact, no window and no serialized execution — +which is a real state (a burst-free interval, a graph refusal that stopped bursts, the first +snapshot of a run), and is exactly the profile whose reader has no other way to learn the tier was +on. A disclosure that appeared only once some counter moved would be absent from precisely those. + +**It is not an anomaly, and it is not counted as one.** The summary gained its own clause and the +identity is now `len(lines) - 1 == warnings + anomalies`, asserted over four snapshots: + +``` +gpu join: 512 executions, all exact; 256 launches, all matched; cache 256 live; pc sampling serialized; 4 standing warning lines; no anomalies +``` + +`anomalousSnapshot()` now sets `PCSampling: PCSamplingSerialized`. It has to: the three +serialization counters it carries are reachable **only** under Tier A, so the fixture as it stood +described a run that cannot happen — and would quietly have stopped exercising the warning that a +real one carries. + +**One anomaly was strengthened while in there.** The `"unknown"` clause was guarded by +`&& SamplingWindowsReceived > 0`, which suppressed it in the worst available case: Tier A +selected, not one window record received, so *every* execution is `"unknown"`. Unknown executions +are unreachable in the other two tiers, so the guard bought nothing and hid the case that most +needed raising. The clause now names which cause applies instead of disappearing: + +``` +gpu join ANOMALY: 512 of 512 executions cannot be said to have run unperturbed — no sampling window covers them (NOT ONE window record reached the agent, though Tier A was selected — the producer never bursted, the probe never attached, or every batch was lost). They are marked gpu_serialized="unknown" and MUST NOT be read as "false" +``` + +--- + +## "off" means off, asserted at the probe site + +The claim is about what leaves the producer, and every cheaper way of checking it checks something +else. A counter says what the producer *thinks* it emitted. A consumer-side assertion says what +survived a ringbuf. Reading the env in a unit test says what the parser returned. Seventeen +defects on this project have been counters and checks reading green exactly when things were +worst, so the assertion is made **where a uprobe would make it**. + +`shim/stub/pc_tier_test.cc` reads its own `.note.stapsdt`, patches the probe nops with `int3` — +which is all a uprobe does — and counts the traps in a `SIGTRAP` handler. No `CAP_BPF`, no +consumer, no GPU. It traps the four PC-sampling probes (`gpu_pc_sample_batch_v1`, +`gpu_stall_reason_map_v1`, `gpu_config_v1`, `gpu_sampling_window_v1`) **and** `gpu_launch_v1` / +`gpu_exec_v1` as controls, arms every semaphore, and leaves the stub's own PC knobs turned **up** +(`PERFAGENT_STUB_PC_SAMPLES=128`, `PERFAGENT_STUB_SAMPLING_WINDOWS=4`) for every pass — the tier +must silence the producer while everything else is asking it to speak. + +``` +pc_tier_test: tier unset ok - pc_sample=0 stall_map=0 config=0 window=0 (launch=2 exec=2) +pc_tier_test: tier=off ok - pc_sample=0 stall_map=0 config=0 window=0 (launch=2 exec=2) +pc_tier_test: tier=0 ok - pc_sample=0 stall_map=0 config=0 window=0 (launch=2 exec=2) +pc_tier_test: tier=continuous ok - pc_sample=4 stall_map=8 config=1 window=0 (launch=2 exec=2) +pc_tier_test: tier=serialized ok - pc_sample=4 stall_map=8 config=1 window=8 (launch=2 exec=2) +pc_tier_test: tier=nonsense ok - pc_sample=0 stall_map=0 config=0 window=0 (launch=2 exec=2) +pc_tier_test: tier=continuous,serialized ok - pc_sample=0 stall_map=0 config=0 window=0 (launch=2 exec=2) +pc_tier_test: tier=serialized,continuous ok - pc_sample=0 stall_map=0 config=0 window=0 (launch=2 exec=2) +``` + +Non-vacuity is the other half and is asserted three ways, because an "off" pass that trapped +nothing would be equally green if the producer had simply failed to run: the launch and exec +probes must fire on **every** pass including the off ones; the `continuous` pass must fire the +sample, stall and config probes, proving those sites are reachable in this very binary; and the +`serialized` pass must fire the window probe. Note also that `continuous` fires **no** window +record: a window is Tier A's own disclosure, and a `CONTINUOUS` producer announcing one would be +claiming a perturbation it did not cause. + +**The test was mutation-checked.** Removing the tier gate from `stub.cc`'s `pc_samples` makes the +first three passes fail with `gpu_pc_sample_batch_v1 FIRED 4 times (128 records) with the tier +off`. It is a check that can go red. + +This required the stub to honour `PERFAGENT_GPU_PC_SAMPLING` at all, which it did not: its PC +emission was gated only on its own knobs. The stub is the producer the agent hands its selection +to on a machine with no GPU, so "off means off" is only assertable there if it obeys the same +setting the adapter does, from the same parser. The tier is now the **outer** gate and the stub +knobs the inner one. No existing test set those knobs, so nothing on the wire changed for any +current gate. + +The agent half is narrower and still load-bearing: +`TestOffIsHandedToTheProducerExplicitly` pins that `off` is written to the child's environment +**explicitly** on every run, and `cmd/gpu-cuda-profile` appends it after `os.Environ()`. Whatever +an operator exported into their shell last week must not reach the producer of a run this agent +believes is off. + +--- + +## What else is pinned + +- `TestTheShimAndTheAgentAgreeOnTheTierSpellings` reads `shim/core/pctier.h` from the Go test and + asserts the two parsers accept the same six spellings. A spelling only one end knows is a + setting that silently does nothing — for `serialized` that hands the operator an unperturbed + profile they will read as a perturbed one; for `off` it would be the opposite. +- `core/pctier_test.cc` — the producer parser: the three values in both spellings, case and + whitespace, the null and empty settings, one tier named twice (redundant, not contradictory), + both-tier orderings, the near-misses an operator actually types (`on`, `true`, `3`, + `serialised`), a good token beside a bad one, the offending text reaching the log, a token + longer than the log buffer truncating rather than overrunning it (this code runs inside somebody + else's process), a null buffer, and `pc_tier_name` on an out-of-range value rendering `invalid` + rather than `off`. +- `gpu/tier_test.go` — the three values, both both-set shapes, the unknown-value error, the + acknowledgement gate from both sources, source attribution, the JSON round trip by name, and + that only `PCSamplingSerialized` consults the window store while the other two still **ingest + and count** windows that arrive anyway. +- The sum identity and every Task 10 assertion still hold; `assertSumIdentity` runs in the new + timeline tests too. + +## Counters and where each refusal is visible + +| condition | agent | producer | +| --- | --- | --- | +| unknown value | startup error, `ErrPCSamplingUnknownTier` | log + `g_pc_tier_refused` (adapter), log (stub) | +| two tiers named | startup error, `ErrPCSamplingTiersExclusive` | log + `g_pc_tier_refused`, stub log | +| flag vs env disagree | startup error, `ErrPCSamplingTiersExclusive` | n/a — the agent never writes a conflicting value | +| `serialized` unacknowledged | startup error, `ErrPCSamplingNotAcknowledged` | n/a — never launched | +| tier in force | `Snapshot.PCSampling`, `joinhealth` summary clause | adapter `pc … tier=`, stub `pc_sampling=` | + +The stub's `pc_sampling=` line is printed on **every** run including an off one, for the reason +that file already states about its cubin counters: a line that appears only on the interesting +runs is a line nobody checks on the boring ones, and the boring one is exactly where "off did not +mean off" would hide. + +--- + +## Verification actually run + +``` +make -C shim exit 0 +make -C shim test exit 0 (pctier_test ok, pc_tier_test 8/8 ok, plus every + pre-existing test: burst, pcdrain, cubinqueue, + probe_order, probe_args, usdt_abi, sampler, enroll) +make -C shim check-fpless OK +make -C shim check-cubin-defer OK - the compliant capture compiles, all 5 deferrals do not +make -C shim nvidia exit 0 (CUDA 13.3, real libcupti) +go build ./... && go vet ./... clean +go test ./gpu/ ./gpuprobe/ ./internal/... -count=1 all pass +go test ./gpu/ ./gpuprobe/ -race -count=4 ok gpu 21.2s, gpuprobe 19.8s +~/go/bin/golangci-lint run --timeout=5m 0 issues +``` + +Plus the mutation check described above: with the stub's tier gate removed, `pc_tier_test` fails +on the three off passes and names the probe that fired. + +--- + +## Cannot verify + +`CapEff: 0`, no GPU on this machine, and **no `CUpti_` code path added or changed by this commit +has ever run.** + +The plan says of this task: *"Must be measured on the RTX 3090 afterwards: nothing beyond what +Tasks 6 and 10 already cover."* That remains true — this task adds no CUPTI call. What it does add +to the hardware list is small and worth stating rather than assuming: + +1. **That the adapter's tier parse runs before any `CONTEXT_CREATED` callback can reach the PC + path.** It replaces an `env_uint` call in the same position, before `cuptiSubscribe`, so this + is believed safe by construction for the same reason Task 6 item 17 was — and unverified in + practice for the same reason. +2. **That `g_pc_tier_refused` is reachable on hardware.** It is reachable in the stub (three + passes of `pc_tier_test` exercise the same parser through the same shapes), and the adapter's + arm is a copy of it, but the adapter's report line has not been printed on a real run. +3. **That the agent's explicit `PERFAGENT_GPU_PC_SAMPLING=off` actually wins in the injected + process.** `os/exec` deduplicates its environment keeping the last occurrence, and the value is + appended after `os.Environ()`, so an inherited export is overridden — asserted by reading the + Go standard library's behaviour, not by observing a CUDA process's `/proc//environ`. +4. **Everything Tasks 6 and 10 could not verify** remains unverified; in particular Task 10's item + 3, whether `COLLECTION_MODE` can change between `Stop` and `Start`, which is the gate on + mid-run tier switching ever being possible. This task is written so that the answer changes + nothing here if it is "no". + +Not verifiable at all, on hardware or otherwise, and unchanged by this task: MPS and cross-process +contention for the sampling hardware. diff --git a/cmd/gpu-cuda-profile/main.go b/cmd/gpu-cuda-profile/main.go index 14ae1c4..dff2f5f 100644 --- a/cmd/gpu-cuda-profile/main.go +++ b/cmd/gpu-cuda-profile/main.go @@ -17,6 +17,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "time" "github.com/dpsoft/perf-agent/gpu" @@ -35,9 +36,48 @@ func main() { period = flag.Int("period", 8, "one-in-N launch sampling period (PERFAGENT_GPU_SAMPLE_PERIOD)") linger = flag.Int("linger-ms", 30000, "how long the workload may wait to be released after it finishes") out = flag.String("out", "gpu-cuda.pb.gz", "output pprof profile") + + // One setting, three values, and no way to ask for two. The default + // is the empty string rather than "off" so that an unspecified flag + // DEFERS to an inherited PERFAGENT_GPU_PC_SAMPLING instead of + // contradicting it — an explicit --gpu-pc-sampling=off against an + // exported "serialized" is a disagreement and is refused, but not + // setting the flag at all is not. + pcSampling = flag.String("gpu-pc-sampling", "", + "GPU PC-sampling tier: "+strings.Join(gpu.PCSamplingTierNames, " | ")+ + " (default off; also read from "+gpu.PCSamplingEnvVar+"). "+ + "\"continuous\" does not serialize kernels; \"serialized\" does, and requires "+ + "-gpu-pc-sampling-acknowledge-perturbation") + pcAck = flag.Bool("gpu-pc-sampling-acknowledge-perturbation", false, + "acknowledge that the \"serialized\" tier perturbs the workload: it inflates GPU "+ + "kernel durations inside a burst, it distorts any CPU and off-CPU profile taken "+ + "alongside it with no marking in those profiles at all, and it is unavailable "+ + "where CUDA graphs are in use") ) flag.Parse() + // Tier selection, and it happens BEFORE anything is attached or launched. + // Every refusal here is a startup error: an unknown value, a value naming + // two tiers, the flag and the environment naming two tiers, or Tier A + // without its acknowledgement. None of them is resolved to a tier — a + // profile produced under a tier nobody chose is worse than no profile, + // because nothing in it says which one ran. + tier, err := gpu.PCSamplingRequest{ + Flag: *pcSampling, + Env: os.Getenv(gpu.PCSamplingEnvVar), + AcknowledgePerturbation: *pcAck, + }.Select() + if err != nil { + log.Fatalf("gpu pc sampling: %v", err) + } + // Printed at startup as well as standing in every JoinHealth render + // below. The startup copy is for the operator who is watching the run + // begin; the standing copy is for the one who reads the profile an hour + // later, which is the reader the warning is actually for. + for _, line := range gpu.PCSamplingStandingWarning(tier) { + log.Print(line) + } + shimPath, err := filepath.Abs(*shim) if err != nil { log.Fatalf("shim path: %v", err) @@ -50,7 +90,11 @@ func main() { log.Fatalf("adapter %s: %v (build it with: make -C shim nvidia)", shimPath, err) } - timeline := gpu.NewTimeline(gpu.TimelineConfig{}) + // The selected tier reaches the agent's own join here and the producer's + // environment below, from ONE variable. Two copies that could disagree + // about which tier ran is how a profile ends up disclosing one thing and + // doing another. + timeline := gpu.NewTimeline(gpu.TimelineConfig{PCSampling: tier}) // Without a symbolizer the sampled launch stacks still arrive and are // still accounted for, but every one of them degrades to no stack — the // profile would then be honest and useless, all GPU time unattributed. @@ -113,6 +157,12 @@ func main() { "CUDA_INJECTION64_PATH="+shimPath, fmt.Sprintf("PERFAGENT_GPU_SAMPLE_PERIOD=%d", *period), "PERFAGENT_GPU_LOG=stderr", + // Set EXPLICITLY on every run including an off one, never left to be + // inherited. os.Environ() may already carry this variable from the + // operator's shell; appending the resolved value last is what keeps a + // stale export from turning a run this agent believes is off into a + // producer that serializes the workload's kernels. + gpu.PCSamplingEnvVar+"="+tier.EnvValue(), ) cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr // Same release protocol as the stub: the workload's CPU stacks are diff --git a/gpu/conformance_test.go b/gpu/conformance_test.go index 694e98e..db7f88a 100644 --- a/gpu/conformance_test.go +++ b/gpu/conformance_test.go @@ -307,7 +307,7 @@ func assertPCAttribAccompaniesSamples(t *testing.T, snap Snapshot) { // three outcomes, so they sum to len(snap.Executions). // // It also asserts the negative that matters more than the sum: with the -// default harness configuration (SerializedSampling unset — nothing is ever +// default harness configuration (PCSampling off — nothing is ever // serialized) NO execution may read "true" or "unknown". A conformance run // that started reporting perturbation nobody caused would be as wrong as one // that stopped reporting perturbation that happened. diff --git a/gpu/joinhealth.go b/gpu/joinhealth.go index 81ed5a9..2876c8f 100644 --- a/gpu/joinhealth.go +++ b/gpu/joinhealth.go @@ -22,9 +22,18 @@ func plural(n uint64, one, many string) string { } // JoinHealth renders a Snapshot's join and loss counters as operator-facing -// lines: element 0 is always a one-line summary, and every element after it -// is one anomaly that the summary's trailing count agrees with. Callers log -// them one per line (see cmd/gpu-stub-profile, cmd/gpu-cuda-profile). +// lines: element 0 is always a one-line summary, then any STANDING WARNINGS +// (see PCSamplingStandingWarning — Tier A's whole-run perturbation notice), +// then one line per anomaly. The summary's trailing counts agree with both +// groups, so len(lines)-1 is always warnings+anomalies. Callers log them one +// per line (see cmd/gpu-stub-profile, cmd/gpu-cuda-profile). +// +// A standing warning is not an anomaly and is kept apart from them on +// purpose: an anomaly is something that went wrong, while a warning is a +// consequence of what the operator deliberately asked for. Both are printed +// on every render — the warning especially, because a perturbation notice +// shown once at startup has scrolled away long before the profile it applies +// to is read. // // The shape is deliberate. Printing the whole counter set on every run - the // obvious `%+v` - is what makes a rising UnmatchedExecutionCount invisible: @@ -75,8 +84,18 @@ func JoinHealth(snap Snapshot) []string { // ProjectExecutionsWith reports no suppression - correctly, since without that // call nothing suppressed anything. func JoinHealthWith(snap Snapshot, proj ProjectionStats) []string { + // Warnings BEFORE anomalies, for the same reason the serialization + // anomaly is raised before the join ones: they qualify the numbers + // themselves rather than what those numbers were attributed to. A + // perturbed measurement joined perfectly is still a perturbed + // measurement, and a reader who stops after the first two lines must have + // been told that. + warnings := PCSamplingStandingWarning(snap.PCSampling) anomalies := joinAnomalies(snap, proj) - return append([]string{joinSummary(snap, len(anomalies))}, anomalies...) + out := make([]string, 0, 1+len(warnings)+len(anomalies)) + out = append(out, joinSummary(snap, len(warnings), len(anomalies))) + out = append(out, warnings...) + return append(out, anomalies...) } // joinSummary is the always-printed line. len(snap.Executions), not a @@ -85,7 +104,7 @@ func JoinHealthWith(snap Snapshot, proj ProjectionStats) []string { // parenthesised breakdown checkable against a figure that does not come // from the same counters it is auditing. joinAnomalies performs that check // rather than leaving it to the reader's arithmetic. -func joinSummary(snap Snapshot, anomalies int) string { +func joinSummary(snap Snapshot, warnings, anomalies int) string { js := snap.JoinStats execs := uint64(len(snap.Executions)) @@ -147,6 +166,15 @@ func joinSummary(snap Snapshot, anomalies int) string { plural(uint64(snap.PendingModuleGroups), "kernel group", "kernel groups")) } + // Which tier this run selected, and only when one was. "off" is the + // default and printing it on every run is the zero-valued noise this + // format exists to avoid; "continuous" and "serialized" are both facts a + // reader needs before they read anything else, because they decide what a + // PC sample can be attributed to and whether the durations are perturbed. + if snap.PCSampling != PCSamplingOff { + fmt.Fprintf(&b, "; pc sampling %s", snap.PCSampling) + } + // The serialization disclosure, and only when there is one to make. With // PC sampling off or in continuous collection every execution is "false" // and nothing was ever serialized, so a permanent "0 serialized" clause @@ -159,6 +187,20 @@ func joinSummary(snap Snapshot, anomalies int) string { plural(uint64(snap.SamplingWindowsHeld), "burst", "bursts")) } + // Counted, and counted in LINES, so that the trailing figures still add + // up to exactly what follows the summary: len(lines)-1 == warnings + + // anomalies. A standing warning is not an anomaly — nothing went wrong, + // the operator asked for it — but it is not free either, and a summary + // that said "no anomalies" while four lines of perturbation warning + // followed it would be the reassuring half of a contradiction. + switch warnings { + case 0: + case 1: + b.WriteString("; 1 standing warning line") + default: + fmt.Fprintf(&b, "; %d standing warning lines", warnings) + } + switch anomalies { case 0: b.WriteString("; no anomalies") @@ -231,12 +273,23 @@ func joinAnomalies(snap Snapshot, proj ProjectionStats) []string { "distorted too and carry no marking at all", snap.ExecutionsSerialized, execs) } - if snap.ExecutionsSerializationUnknown > 0 && snap.SamplingWindowsReceived > 0 { + // No `&& SamplingWindowsReceived > 0` guard. Unknown executions are + // reachable only under Tier A (the other two tiers answer "false" + // unconditionally and never consult the store), and the case the guard + // would suppress — Tier A selected, not one window record arrived, so + // EVERY execution is "unknown" — is the worst one available, not the + // uninteresting one. The clause names whether any window arrived instead + // of hiding the line when none did. + if snap.ExecutionsSerializationUnknown > 0 { + cause := "a dropped batch, a late attach, a sequence gap, or a burst that never closed" + if snap.SamplingWindowsReceived == 0 { + cause = "NOT ONE window record reached the agent, though Tier A was selected — the " + + "producer never bursted, the probe never attached, or every batch was lost" + } add("%d of %d executions cannot be said to have run unperturbed — no sampling window "+ - "covers them (a dropped batch, a late attach, a sequence gap, or a burst that "+ - "never closed). They are marked gpu_serialized=\"unknown\" and MUST NOT be read "+ + "covers them (%s). They are marked gpu_serialized=\"unknown\" and MUST NOT be read "+ "as \"false\"", - snap.ExecutionsSerializationUnknown, execs) + snap.ExecutionsSerializationUnknown, execs, cause) } if snap.SamplingWindowsOpen > 0 { add("%s still open — the producer stopped reporting mid-burst (a hard exit), so the "+ diff --git a/gpu/joinhealth_test.go b/gpu/joinhealth_test.go index fd1d1e6..fc38b38 100644 --- a/gpu/joinhealth_test.go +++ b/gpu/joinhealth_test.go @@ -67,6 +67,14 @@ func anomalousSnapshot() Snapshot { // Tier A gone wrong in all three ways at once: bursts perturbed some // executions, an unbroken history proved others were untouched, and a // window that never closed leaves the rest unplaceable. + // + // The tier is set, and it has to be: the three counters below are + // reachable ONLY under PCSamplingSerialized (the other two tiers + // answer "false" unconditionally and never consult the window store), + // so a fixture that carried them with the tier off would be a run that + // cannot happen — and would quietly stop exercising the standing + // warning that a real one carries. + PCSampling: PCSamplingSerialized, SamplingWindowsReceived: 41, SamplingWindowsHeld: 21, SamplingWindowsOpen: 1, @@ -89,10 +97,17 @@ func TestJoinHealthAnomaliesEachGetTheirOwnLine(t *testing.T) { lines := JoinHealth(anomalousSnapshot()) require.Greater(t, len(lines), 1) + warnings := 0 for _, l := range lines[1:] { + if strings.HasPrefix(l, PCSamplingWarningPrefix+": ") { + warnings++ + continue + } assert.True(t, strings.HasPrefix(l, joinAnomalyPrefix+": "), "line %q", l) assert.Contains(t, l, " — ", "every anomaly says what the number means when it is bad") } + assert.Equal(t, len(PCSamplingStandingWarning(PCSamplingSerialized)), warnings, + "a Tier A snapshot carries its whole standing warning, every render") joined := strings.Join(lines, "\n") for _, want := range []string{ @@ -118,28 +133,107 @@ func TestJoinHealthAnomaliesEachGetTheirOwnLine(t *testing.T) { } } -// The summary's anomaly count is the one derived figure here; it must never -// read green when things are worst. +// The summary's trailing counts are the only derived figures here; they must +// never read green when things are worst, and together they must account for +// EVERY line below the summary. A standing warning line that no count covered +// would be a line the summary implicitly denies exists. func TestJoinHealthSummaryCountMatchesTheLinesBelowIt(t *testing.T) { + tierA := healthySnapshot() + tierA.PCSampling = PCSamplingSerialized for name, snap := range map[string]Snapshot{ - "healthy": healthySnapshot(), - "anomalous": anomalousSnapshot(), - "empty": {}, + "healthy": healthySnapshot(), + "anomalous": anomalousSnapshot(), + "empty": {}, + "tier A, no fault": tierA, } { t.Run(name, func(t *testing.T) { lines := JoinHealth(snap) - switch n := len(lines) - 1; n { + + warnings := 0 + for _, l := range lines[1:] { + if strings.HasPrefix(l, PCSamplingWarningPrefix+": ") { + warnings++ + } + } + anomalies := len(lines) - 1 - warnings + + switch warnings { + case 0: + assert.NotContains(t, lines[0], "standing warning") + case 1: + assert.Contains(t, lines[0], "; 1 standing warning line") + default: + assert.Contains(t, lines[0], "; "+strconv.Itoa(warnings)+" standing warning lines") + } + + switch anomalies { case 0: assert.Contains(t, lines[0], "; no anomalies") case 1: assert.Contains(t, lines[0], "; 1 anomaly") default: - assert.Contains(t, lines[0], "; "+strconv.Itoa(n)+" anomalies") + assert.Contains(t, lines[0], "; "+strconv.Itoa(anomalies)+" anomalies") } }) } } +// The warning STANDS. It is rendered on a Tier A run in which nothing at all +// went wrong — no perturbed execution yet, no unknown, no window even — and +// that is the case it exists for: a burst-free interval, a graph refusal that +// stopped bursts, or simply the first snapshot of a run. A disclosure that +// appeared only once some counter moved would be absent from exactly the +// profiles whose readers had no other way to learn the tier was on. +func TestTheTierAWarningStandsOnAnOtherwisePerfectRun(t *testing.T) { + snap := healthySnapshot() + snap.PCSampling = PCSamplingSerialized + // Nothing is amiss: every join exact, no window, no serialized execution. + snap.ExecutionsNotSerialized = uint64(len(snap.Executions)) + + lines := JoinHealth(snap) + joined := strings.Join(lines, "\n") + assert.Contains(t, lines[0], "; pc sampling serialized") + assert.Contains(t, lines[0], "; no anomalies") + assert.Contains(t, joined, "CARRY NO MARKING AT ALL") + assert.Contains(t, joined, "CUDA GRAPHS") + assert.Equal(t, PCSamplingStandingWarning(PCSamplingSerialized), lines[1:], + "the warning is the whole warning, in order, immediately under the summary") +} + +// And it is absent for the two tiers that do not perturb anything. A warning +// on every run is a warning readers learn to skip. +func TestNoStandingWarningWhenNothingIsPerturbed(t *testing.T) { + for _, tier := range []PCSamplingTier{PCSamplingOff, PCSamplingContinuous} { + t.Run(tier.String(), func(t *testing.T) { + snap := healthySnapshot() + snap.PCSampling = tier + lines := JoinHealth(snap) + require.Len(t, lines, 1) + assert.NotContains(t, lines[0], "standing warning") + if tier == PCSamplingOff { + assert.NotContains(t, lines[0], "pc sampling") + } else { + assert.Contains(t, lines[0], "; pc sampling continuous") + } + }) + } +} + +// Tier A selected and NOT ONE window record received: every execution is +// "unknown", and that is the worst available state of the disclosure rather +// than a quiet one. It must be raised, and the line must say that no window +// arrived at all rather than offering the ordinary lossy-transport causes. +func TestJoinHealthRaisesTierAWithNoWindowAtAll(t *testing.T) { + snap := healthySnapshot() + snap.PCSampling = PCSamplingSerialized + snap.ExecutionsSerializationUnknown = uint64(len(snap.Executions)) + snap.ExecutionsNotSerialized = 0 + + joined := strings.Join(JoinHealth(snap), "\n") + assert.Contains(t, joined, "NOT ONE window record reached the agent") + assert.Contains(t, joined, "MUST NOT be read") +} + // A snapshot with nothing in it is the degenerate worst case: every ratio a // health figure could compute is 0/0. It must read as an anomaly, not as a // clean run. @@ -258,6 +352,12 @@ func TestJoinHealthRenderedOutput(t *testing.T) { {"healthy", healthySnapshot()}, {"anomalous", anomalousSnapshot()}, {"empty", Snapshot{}}, + {"tier A, nothing wrong", func() Snapshot { + s := healthySnapshot() + s.PCSampling = PCSamplingSerialized + s.ExecutionsNotSerialized = uint64(len(s.Executions)) + return s + }()}, } { t.Log(c.name + ":\n" + strings.Join(JoinHealth(c.snap), "\n")) } diff --git a/gpu/serialization_test.go b/gpu/serialization_test.go index d16ac83..bcf2c6e 100644 --- a/gpu/serialization_test.go +++ b/gpu/serialization_test.go @@ -21,7 +21,7 @@ import ( const tierAPID = uint32(4242) func tierATimeline() *Timeline { - return NewTimeline(TimelineConfig{SerializedSampling: true}) + return NewTimeline(TimelineConfig{PCSampling: PCSamplingSerialized}) } // serializedExec is an execution from tierAPID over [startNs, endNs]. The PID @@ -357,7 +357,7 @@ func TestSerializationWindowsNeverCrossProcesses(t *testing.T) { // That can only turn "false" into "unknown" — never the reverse — and this // asserts the direction rather than trusting the comment. func TestSerializationEvictionDegradesTowardsUnknownNeverTowardsFalse(t *testing.T) { - tl := NewTimeline(TimelineConfig{SerializedSampling: true, MaxSamplingWindowsPerPID: 4}) + tl := NewTimeline(TimelineConfig{PCSampling: PCSamplingSerialized, MaxSamplingWindowsPerPID: 4}) // The gap between bursts 1 and 2 is provable while both are held. emitBurst(t, tl, uint64(tierAPID), 1000, 2000) emitBurst(t, tl, uint64(tierAPID), 3000, 4000) @@ -382,7 +382,7 @@ func TestSerializationEvictionDegradesTowardsUnknownNeverTowardsFalse(t *testing // A process past the PID bound gets no window history, so its executions read // "unknown". It must not inherit somebody else's. func TestSerializationRefusedPIDReadsUnknown(t *testing.T) { - tl := NewTimeline(TimelineConfig{SerializedSampling: true, MaxSamplingWindowPIDs: 1}) + tl := NewTimeline(TimelineConfig{PCSampling: PCSamplingSerialized, MaxSamplingWindowPIDs: 1}) emitBurst(t, tl, 1, 1000, 2000) emitBurst(t, tl, 2, 1000, 2000) // refused: the store already holds one PID diff --git a/gpu/tier.go b/gpu/tier.go new file mode 100644 index 0000000..912b0a5 --- /dev/null +++ b/gpu/tier.go @@ -0,0 +1,354 @@ +package gpu + +import ( + "errors" + "fmt" + "strings" +) + +// --------------------------------------------------------------------------- +// Tier selection: one setting, three values, and why "both" is an error. +// +// GPU PC sampling has two collection tiers and they cannot run at the same +// time. The reason matters more than the rule, because a reader who knows only +// the rule will eventually try to route around it: +// +// CUPTI's COLLECTION_MODE is a single per-CUcontext attribute. A process could +// in principle set KERNEL_SERIALIZED on one context and CONTINUOUS on another +// -- nothing in CUPTI forbids it. What forbids it is that WHICH CONTEXT A +// GIVEN KERNEL LANDS ON IS THE APPLICATION'S CHOICE, NOT THE PROFILER'S. A +// "both" mode would therefore produce one profile in which some kernels carry +// exact launch attribution and perturbed durations while others carry inferred +// attribution and honest durations, split along an axis the operator can +// neither see nor control. That is worse than either tier alone: a profile +// whose trustworthiness varies invisibly is not a profile, it is a hazard. +// +// So the selection is PROCESS-WIDE AND EXCLUSIVE. Naming two tiers is a +// startup error, logged loudly, and never resolved by a silent pick -- not +// "last one wins", not "the safer one wins". Both of those are decisions the +// operator did not make and cannot see in the output. +// +// Switching tiers mid-run is out of scope. It is gated on the Task 10 hardware +// question of whether COLLECTION_MODE can be changed between +// cuptiPCSamplingStop and cuptiPCSamplingStart without a full Disable/Enable, +// which cupti_pcsampling.h does not answer and no machine here can. +// --------------------------------------------------------------------------- + +// PCSamplingEnvVar is the one variable that carries the selected tier to the +// producer. The CUPTI adapter (shim/nvidia/cupti_adapter.cc) and the GPU-free +// stub (shim/stub/stub.cc) both read it through shim/core/pctier.h, so the two +// producers cannot drift apart on what "off" means. +// +// It is set EXPLICITLY on the producer's environment for every tier including +// off, never left to be inherited: an operator who exported +// PERFAGENT_GPU_PC_SAMPLING=serialized in their shell last week must not have +// it leak into a run this agent believes is off. +const PCSamplingEnvVar = "PERFAGENT_GPU_PC_SAMPLING" + +// PCSamplingTier is the selected PC-sampling collection tier. +// +// The zero value is PCSamplingOff, and that is load-bearing in the same way +// SerializationUnknown's zero value is: a config nobody filled in, a struct a +// test built by hand, or a field lost in a copy all land on "off". Turning PC +// sampling ON has to be written deliberately somewhere. +type PCSamplingTier uint8 + +const ( + // PCSamplingOff is the default. Off means OFF: the producer makes no + // PC-sampling call at all, allocates no PC buffers, enables no extra + // CUPTI domain and fires no PC-sampling probe. It is not "enabled but + // idle" and not "enabled at a low rate". + PCSamplingOff PCSamplingTier = iota + + // PCSamplingContinuous is Tier B: CUPTI_PC_SAMPLING_COLLECTION_MODE_ + // CONTINUOUS. Kernels are not serialized, so this is the only tier that + // is a candidate for always-on profiling. The price is that + // correlationId is zero on every PC record, so a sample joins to its + // kernel through the module and never to the launch that issued it. + PCSamplingContinuous + + // PCSamplingSerialized is Tier A: CUPTI_PC_SAMPLING_COLLECTION_MODE_ + // KERNEL_SERIALIZED, duty-cycled in bursts. CUPTI populates + // correlationId on every record, so a sample joins to a launch -- and + // therefore to a CPU stack -- exactly. The price is that every kernel + // that runs while a burst is open RUNS SERIALIZED, which perturbs the + // very durations the profile reports, and perturbs the CPU and off-CPU + // profiles taken alongside it with no marking there at all. + // + // It is refused unless the operator acknowledges that explicitly. See + // PCSamplingRequest.Select and PCSamplingStandingWarning. + PCSamplingSerialized +) + +// The three spellings, and the only three. They are exported because they are +// what an operator types and what a --help string must quote; a driver that +// spelled a fourth one would be inventing a tier. +const ( + PCSamplingNameOff = "off" + PCSamplingNameContinuous = "continuous" + PCSamplingNameSerialized = "serialized" +) + +// PCSamplingTierNames lists the three legal values in the order a help string +// should show them: the default first, then increasing cost to the workload. +var PCSamplingTierNames = []string{ + PCSamplingNameOff, PCSamplingNameContinuous, PCSamplingNameSerialized, +} + +// The errors. They are sentinels rather than strings so a driver can tell a +// misconfiguration (exit with usage) from a refusal it must explain +// (ErrPCSamplingNotAcknowledged has an instruction attached), and so tests +// assert on the CONDITION rather than on wording that will be edited. +var ( + // ErrPCSamplingUnknownTier: the value is not one of the three. + ErrPCSamplingUnknownTier = errors.New("unknown GPU PC-sampling tier") + + // ErrPCSamplingTiersExclusive: more than one tier was named, either in + // one value or across the flag and the environment. This is the + // process-wide exclusivity rule; see the file header for why it is a + // startup error and not a pick. + ErrPCSamplingTiersExclusive = errors.New("GPU PC-sampling tiers are mutually exclusive") + + // ErrPCSamplingNotAcknowledged: "serialized" was selected without the + // operator acknowledging that it perturbs the workload. Tier A is a + // destructive flag in the ordinary sense -- it changes the thing being + // measured -- so it takes the same shape as one. + ErrPCSamplingNotAcknowledged = errors.New("GPU PC-sampling tier \"serialized\" perturbs the workload and was not acknowledged") +) + +// String renders the tier as the value an operator types. An out-of-range +// value renders as an explicit "invalid(N)" rather than as "off": a tier that +// fell out of a bad conversion must not read as the safe default, because +// "off" is exactly the answer nobody would investigate. +func (t PCSamplingTier) String() string { + switch t { + case PCSamplingOff: + return PCSamplingNameOff + case PCSamplingContinuous: + return PCSamplingNameContinuous + case PCSamplingSerialized: + return PCSamplingNameSerialized + default: + return fmt.Sprintf("invalid(%d)", uint8(t)) + } +} + +// Valid reports whether t is one of the three defined tiers. +func (t PCSamplingTier) Valid() bool { + return t <= PCSamplingSerialized +} + +// EnvValue is what goes into PCSamplingEnvVar on the producer's environment. +// +// The NAME, not a number. Both producers accept the numeric spellings too (an +// operator's exported PERFAGENT_GPU_PC_SAMPLING=2 still works), but what this +// agent writes is legible in `ps eauxwww` and in a container spec, where "2" +// is a value somebody has to go look up and may guess wrong about. +func (t PCSamplingTier) EnvValue() string { return t.String() } + +// MarshalText makes a serialized Snapshot say "serialized" rather than "2". +// The Snapshot is an operator-facing artifact; a numeric tier in it would be +// one more thing to decode correctly under time pressure. +func (t PCSamplingTier) MarshalText() ([]byte, error) { return []byte(t.String()), nil } + +// UnmarshalText round-trips MarshalText, and rejects what ParsePCSamplingTier +// rejects. A JSON document naming two tiers fails to decode rather than +// decoding to one of them. +func (t *PCSamplingTier) UnmarshalText(b []byte) error { + parsed, err := ParsePCSamplingTier(string(b)) + if err != nil { + return err + } + *t = parsed + return nil +} + +// pcSamplingTierByName maps one token to a tier. The numeric spellings exist +// because PCSamplingEnvVar is a plain environment variable that operators and +// container specs have already been setting as 0/1/2, and silently ignoring +// those would turn a configured Tier A run into a quiet Tier-off one. +func pcSamplingTierByName(tok string) (PCSamplingTier, bool) { + switch tok { + case PCSamplingNameOff, "0": + return PCSamplingOff, true + case PCSamplingNameContinuous, "1": + return PCSamplingContinuous, true + case PCSamplingNameSerialized, "2": + return PCSamplingSerialized, true + default: + return PCSamplingOff, false + } +} + +// ParsePCSamplingTier turns one setting's text into a tier. +// +// The empty string is PCSamplingOff: an unset environment variable and an +// unspecified flag both mean "the default", and the default is off. +// +// A value naming MORE THAN ONE tier is the exclusivity error, not a pick. It +// is parsed rather than rejected as a syntax error on purpose: "both" has to +// be EXPRESSIBLE for the refusal to be reachable, and a parser that quietly +// took the first token of "continuous,serialized" would be the silent pick +// this rule exists to prevent. +func ParsePCSamplingTier(value string) (PCSamplingTier, error) { + toks := strings.FieldsFunc(value, func(r rune) bool { + return r == ',' || r == '+' || r == ';' || r == ' ' || r == '\t' || r == '\n' + }) + if len(toks) == 0 { + return PCSamplingOff, nil + } + + var ( + named []string + set []PCSamplingTier + ) + for _, tok := range toks { + tier, ok := pcSamplingTierByName(strings.ToLower(tok)) + if !ok { + return PCSamplingOff, fmt.Errorf("%w: %q is not a tier; the three values are %s", + ErrPCSamplingUnknownTier, tok, strings.Join(PCSamplingTierNames, ", ")) + } + dup := false + for _, have := range set { + if have == tier { + dup = true + break + } + } + if !dup { + set = append(set, tier) + named = append(named, tier.String()) + } + } + if len(set) > 1 { + return PCSamplingOff, fmt.Errorf("%w: %q names %s. "+ + "COLLECTION_MODE is a single per-CUcontext CUPTI attribute, and which context a "+ + "kernel lands on is the application's choice rather than the profiler's — so a "+ + "\"both\" mode would produce one profile whose attribution quality varied along an "+ + "axis the operator can neither see nor control. Name exactly one of %s", + ErrPCSamplingTiersExclusive, value, strings.Join(named, " and "), + strings.Join(PCSamplingTierNames, ", ")) + } + return set[0], nil +} + +// PCSamplingRequest is what the operator expressed, before it is a tier: the +// two places a tier can come from, plus the acknowledgement that Tier A +// requires. +// +// Two sources, because there genuinely are two. A driver takes a flag, and the +// producer's environment may already carry PCSamplingEnvVar from a shell +// export or a container spec. Resolving a disagreement between them by +// precedence would be a silent pick of exactly the kind the exclusivity rule +// forbids, so a disagreement is an error and agreement is not. +type PCSamplingRequest struct { + // Flag is the driver's --gpu-pc-sampling value. Empty means the flag was + // not given, which is different from "off" being given: unspecified + // defers to Env, whereas an explicit "off" that contradicts Env is a + // disagreement and is refused. + Flag string + + // Env is the inherited value of PCSamplingEnvVar, as read from the + // agent's own environment (os.Getenv). Empty means unset. + Env string + + // AcknowledgePerturbation is the operator's explicit acknowledgement + // that Tier A perturbs the workload it measures. It gates nothing else: + // off and continuous do not need it and are not affected by it. + AcknowledgePerturbation bool +} + +// Select resolves the request to a tier, or refuses. +// +// It refuses in exactly three ways, and every one of them is a startup error +// rather than a downgrade: +// +// - an unknown value in either source; +// - both sources naming different tiers, or one source naming two; +// - "serialized" without AcknowledgePerturbation. +// +// On any error the returned tier is PCSamplingOff, so a caller that logs and +// exits and a caller that logs and continues both end up not sampling, rather +// than one of them ending up in a tier nobody chose. +func (r PCSamplingRequest) Select() (PCSamplingTier, error) { + fromFlag, err := ParsePCSamplingTier(r.Flag) + if err != nil { + return PCSamplingOff, fmt.Errorf("--gpu-pc-sampling: %w", err) + } + fromEnv, err := ParsePCSamplingTier(r.Env) + if err != nil { + return PCSamplingOff, fmt.Errorf("%s: %w", PCSamplingEnvVar, err) + } + + tier := fromEnv + switch { + case r.Flag != "" && r.Env != "" && fromFlag != fromEnv: + return PCSamplingOff, fmt.Errorf("%w: --gpu-pc-sampling=%s and %s=%s name different "+ + "tiers. The selection is process-wide and there is no precedence between them: "+ + "picking one silently would leave the profile's attribution quality decided by "+ + "which source the operator forgot about. Unset one of them", + ErrPCSamplingTiersExclusive, fromFlag, PCSamplingEnvVar, fromEnv) + case r.Flag != "": + tier = fromFlag + } + + if tier == PCSamplingSerialized && !r.AcknowledgePerturbation { + return PCSamplingOff, fmt.Errorf("%w. Tier A serializes GPU kernels in bursts: it "+ + "inflates the kernel durations this profile reports, it distorts any CPU and "+ + "off-CPU profile taken alongside it with no marking in those profiles at all, and "+ + "it is unavailable where CUDA graphs are in use. Re-run with "+ + "--gpu-pc-sampling-acknowledge-perturbation if that is what you want", + ErrPCSamplingNotAcknowledged) + } + return tier, nil +} + +// PCSamplingWarningPrefix marks the standing Tier A warning. It is distinct +// from joinAnomalyPrefix because the two say different things: an anomaly is +// something that went wrong, while this is something the operator asked for +// and must keep in mind while reading the result. +const PCSamplingWarningPrefix = "gpu pc sampling WARNING" + +// PCSamplingStandingWarning is the whole-run disclosure for Tier A, and it is +// nil for every other tier. +// +// It STANDS. JoinHealthWith emits it on every render, not once at startup, +// because a warning printed before a sixty-second profile is a warning that +// has scrolled off by the time anyone reads the profile it applies to. +// +// It names ALL THREE perturbations, and that is the requirement rather than a +// stylistic preference. An operator told only about the first will read the +// other two backwards: they will see gpu_serialized="true" on the GPU samples, +// conclude that the marked ones are the perturbed ones, and then trust an +// off-CPU profile whose synchronization waits are inflated by exactly this +// mechanism and carry no marking at all. A limitation an operator is told +// about is a limitation; one they discover from a misleading profile is a +// defect. +func PCSamplingStandingWarning(tier PCSamplingTier) []string { + if tier != PCSamplingSerialized { + return nil + } + p := PCSamplingWarningPrefix + ": " + return []string{ + p + "Tier A (\"serialized\", CUPTI KERNEL_SERIALIZED) PC sampling was selected for this " + + "run — the profiler deliberately perturbs the workload it is measuring. This warning " + + "stands for the whole run, not just at startup. It names three distinct " + + "perturbations, because an operator told only about the first will misread the " + + "other two.", + p + "(1) GPU KERNEL DURATIONS INSIDE A BURST ARE INFLATED by serialization — kernels " + + "that would have overlapped ran one at a time. Those executions are marked " + + "gpu_serialized=\"true\"; executions that cannot be shown to have run outside every " + + "burst are marked \"unknown\" and must never be read as \"false\".", + p + "(2) CPU AND OFF-CPU SAMPLES TAKEN DURING A BURST ARE DISTORTED AND CARRY NO MARKING " + + "AT ALL. gpu_serialized reaches only the GPU projection (ProjectExecutions); the " + + "on-CPU and off-CPU profilers are a separate path with no window awareness, and " + + "serialization inflates precisely the synchronization wait that off-CPU profiling " + + "exists to measure. cudaDeviceSynchronize-shaped off-CPU time will look worse than " + + "it is, with nothing in that profile saying why.", + p + "(3) TIER A IS UNAVAILABLE WHERE CUDA GRAPHS ARE IN USE. A graph launch fires one " + + "runtime callback for N kernels, so Tier A's exact-launch attribution would be false " + + "while still looking exact; the producer refuses to open bursts in such a process " + + "rather than downgrading silently to Tier B, and this profile then carries no Tier A " + + "PC samples at all.", + } +} diff --git a/gpu/tier_test.go b/gpu/tier_test.go new file mode 100644 index 0000000..de4347f --- /dev/null +++ b/gpu/tier_test.go @@ -0,0 +1,408 @@ +package gpu + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ---------------------------------------------------------------- the values + +// The three values, in every spelling the setting accepts. The numerals are +// part of the contract, not legacy tolerated by accident: +// PERFAGENT_GPU_PC_SAMPLING has been 0/1/2 since Tier B shipped and container +// specs are already set that way, so a parser that ignored them would turn a +// configured Tier A run into a silent off one. +func TestParsePCSamplingTierAcceptsExactlyThreeValues(t *testing.T) { + for _, c := range []struct { + in string + want PCSamplingTier + }{ + {"off", PCSamplingOff}, + {"0", PCSamplingOff}, + {"", PCSamplingOff}, + {" ", PCSamplingOff}, + {"continuous", PCSamplingContinuous}, + {"1", PCSamplingContinuous}, + {"serialized", PCSamplingSerialized}, + {"2", PCSamplingSerialized}, + // Case and stray whitespace are an operator's typing, not a different + // setting. + {"SERIALIZED", PCSamplingSerialized}, + {" Continuous ", PCSamplingContinuous}, + // Naming one tier twice is redundant, not contradictory. + {"continuous,continuous", PCSamplingContinuous}, + } { + t.Run(strings.TrimSpace(c.in), func(t *testing.T) { + got, err := ParsePCSamplingTier(c.in) + require.NoError(t, err) + assert.Equal(t, c.want, got) + }) + } +} + +// The zero value is off. This is the same discipline SerializationUnknown's +// zero value carries and it exists for the same reason: a config nobody +// filled in, a struct a test built by hand and a field lost in a copy must all +// land on the tier that does not touch the workload. Turning PC sampling on +// has to be written somewhere, deliberately. +func TestPCSamplingTierZeroValueIsOff(t *testing.T) { + var t0 PCSamplingTier + assert.Equal(t, PCSamplingOff, t0) + assert.Equal(t, "off", t0.String()) + assert.Equal(t, "off", t0.EnvValue()) + + var cfg TimelineConfig + assert.Equal(t, PCSamplingOff, cfg.PCSampling) +} + +// An out-of-range tier must not render as "off". A value that fell out of a +// bad conversion reading as the safe default is the one answer nobody would +// investigate. +func TestPCSamplingTierOutOfRangeDoesNotReadAsOff(t *testing.T) { + bogus := PCSamplingTier(9) + assert.False(t, bogus.Valid()) + assert.Equal(t, "invalid(9)", bogus.String()) + assert.NotEqual(t, "off", bogus.String()) +} + +// ------------------------------------------------------------- the refusals + +// The unknown-value error. Every near-miss an operator actually types is here +// rather than one synthetic "xyzzy", because "on" and "true" are what somebody +// reaches for when they remember this as a boolean and "serialised" is what a +// British operator types. +func TestParsePCSamplingTierRefusesUnknownValues(t *testing.T) { + for _, in := range []string{ + "nonsense", "3", "true", "on", "yes", "enabled", "serialised", "tier-a", + // A good token beside a bad one is still a refusal, never the good + // token: the operator wrote something they did not mean, and running + // half of it is running something they did not ask for. + "continuous,nonsense", "nonsense,continuous", + } { + t.Run(in, func(t *testing.T) { + got, err := ParsePCSamplingTier(in) + require.ErrorIs(t, err, ErrPCSamplingUnknownTier) + assert.Equal(t, PCSamplingOff, got, + "a refused setting must fall closed to off, never to a tier nobody chose") + // The error names the three legal values, because a refusal that + // does not say what IS legal makes the operator guess. + for _, name := range PCSamplingTierNames { + assert.Contains(t, err.Error(), name) + } + }) + } +} + +// THE BOTH-SET ERROR, in one setting. +// +// Both orderings are asserted, and that is the assertion rather than a +// courtesy: a parser that quietly took the first token would answer +// "continuous" for one of these and "serialized" for the other — a silent pick +// that looks correct in half the runs and perturbs the workload in the other +// half, with nothing in the profile saying which happened. +func TestParsePCSamplingTierRefusesBothTiersInOneValue(t *testing.T) { + for _, in := range []string{ + "continuous,serialized", "serialized,continuous", "1+2", "2 1", + "continuous serialized", "off,serialized", + } { + t.Run(in, func(t *testing.T) { + got, err := ParsePCSamplingTier(in) + require.ErrorIs(t, err, ErrPCSamplingTiersExclusive) + assert.Equal(t, PCSamplingOff, got) + // The REASON, not just the rule. A reader who knows only the rule + // will eventually try to route around it — one context per tier + // is the obvious idea and CUPTI does not forbid it. + assert.Contains(t, err.Error(), "COLLECTION_MODE") + assert.Contains(t, err.Error(), "application's choice") + }) + } +} + +// THE BOTH-SET ERROR, across the two sources. There genuinely are two: a +// driver takes a flag, and the producer's environment may already carry +// PERFAGENT_GPU_PC_SAMPLING from a shell export or a container spec. +// Resolving that by precedence would leave the profile's attribution quality +// decided by whichever source the operator forgot about. +func TestSelectRefusesWhenTheFlagAndTheEnvironmentDisagree(t *testing.T) { + for _, c := range []struct{ flag, env string }{ + {"continuous", "serialized"}, + {"serialized", "continuous"}, + {"off", "serialized"}, + {"continuous", "0"}, + } { + t.Run(c.flag+"/"+c.env, func(t *testing.T) { + got, err := PCSamplingRequest{ + Flag: c.flag, Env: c.env, AcknowledgePerturbation: true, + }.Select() + require.ErrorIs(t, err, ErrPCSamplingTiersExclusive) + assert.Equal(t, PCSamplingOff, got) + assert.Contains(t, err.Error(), PCSamplingEnvVar) + assert.Contains(t, err.Error(), "--gpu-pc-sampling") + }) + } +} + +// Agreement is not a disagreement, and an unspecified flag is not an "off" +// that contradicts the environment. Without this the exclusivity rule would +// make an exported PERFAGENT_GPU_PC_SAMPLING unusable with any driver that +// has the flag. +func TestSelectResolvesTheSourcesWhenTheyDoNotDisagree(t *testing.T) { + for _, c := range []struct { + name, flag, env string + want PCSamplingTier + }{ + {"neither", "", "", PCSamplingOff}, + {"flag only", "continuous", "", PCSamplingContinuous}, + {"env only", "", "continuous", PCSamplingContinuous}, + {"both, same tier", "continuous", "continuous", PCSamplingContinuous}, + {"both, same tier, different spelling", "serialized", "2", PCSamplingSerialized}, + {"flag defers to env", "", "serialized", PCSamplingSerialized}, + } { + t.Run(c.name, func(t *testing.T) { + got, err := PCSamplingRequest{ + Flag: c.flag, Env: c.env, AcknowledgePerturbation: true, + }.Select() + require.NoError(t, err) + assert.Equal(t, c.want, got) + }) + } +} + +// An unknown value is attributed to the source that carried it, in both +// directions. An operator staring at a correct flag needs to be told the +// environment is what is wrong. +func TestSelectNamesTheSourceOfAnUnreadableValue(t *testing.T) { + _, err := PCSamplingRequest{Flag: "nonsense"}.Select() + require.ErrorIs(t, err, ErrPCSamplingUnknownTier) + assert.Contains(t, err.Error(), "--gpu-pc-sampling") + + _, err = PCSamplingRequest{Env: "nonsense"}.Select() + require.ErrorIs(t, err, ErrPCSamplingUnknownTier) + assert.Contains(t, err.Error(), PCSamplingEnvVar) +} + +// --------------------------------------------------- the acknowledgement gate + +// Tier A is a destructive flag in the ordinary sense — it changes the thing +// being measured — so it takes the same shape as one: refused unless the +// operator said so deliberately, and the refusal explains what they would be +// consenting to rather than just naming a flag. +func TestSerializedIsRefusedWithoutAnExplicitAcknowledgement(t *testing.T) { + for _, source := range []string{"flag", "env"} { + t.Run(source, func(t *testing.T) { + req := PCSamplingRequest{} + if source == "flag" { + req.Flag = "serialized" + } else { + req.Env = "serialized" + } + got, err := req.Select() + require.ErrorIs(t, err, ErrPCSamplingNotAcknowledged) + assert.Equal(t, PCSamplingOff, got, + "an unacknowledged Tier A must not run; it must not run as Tier B either") + + // All three perturbations are named in the refusal itself, not + // only in the standing warning: this text is the last thing an + // operator sees before they decide whether to pass the flag. + assert.Contains(t, err.Error(), "inflates the kernel durations") + assert.Contains(t, err.Error(), "off-CPU profile taken alongside it with no marking") + assert.Contains(t, err.Error(), "CUDA graphs") + assert.Contains(t, err.Error(), "--gpu-pc-sampling-acknowledge-perturbation") + + req.AcknowledgePerturbation = true + got, err = req.Select() + require.NoError(t, err) + assert.Equal(t, PCSamplingSerialized, got) + }) + } +} + +// The acknowledgement gates Tier A and nothing else. An operator who leaves it +// set in a script must not thereby change what "off" or "continuous" does. +func TestTheAcknowledgementGatesOnlySerialized(t *testing.T) { + for _, tier := range []string{"off", "continuous"} { + for _, ack := range []bool{false, true} { + got, err := PCSamplingRequest{Flag: tier, AcknowledgePerturbation: ack}.Select() + require.NoError(t, err) + assert.Equal(t, tier, got.String()) + } + } +} + +// ------------------------------------------------------ "off" means OFF + +// The producer's environment for an off run names off EXPLICITLY. +// +// This is the agent half of the "off means off" assertion; the wire half is +// shim/stub/pc_tier_test.cc, which traps the four PC-sampling probe sites with +// int3 and requires that not one of them fires with the tier off while the +// launch and exec probes still do. Here the claim is narrower and still +// load-bearing: whatever an operator exported into their shell last week must +// not reach the producer of a run this agent believes is off. +func TestOffIsHandedToTheProducerExplicitly(t *testing.T) { + assert.Equal(t, "off", PCSamplingOff.EnvValue()) + assert.Equal(t, "continuous", PCSamplingContinuous.EnvValue()) + assert.Equal(t, "serialized", PCSamplingSerialized.EnvValue()) + + // The value a driver writes is one the producer's own parser reads back as + // the same tier. A driver that wrote "0" and a producer that read names + // only would silently disagree about the safest possible setting. + for _, tier := range []PCSamplingTier{PCSamplingOff, PCSamplingContinuous, PCSamplingSerialized} { + back, err := ParsePCSamplingTier(tier.EnvValue()) + require.NoError(t, err) + assert.Equal(t, tier, back) + } +} + +// With the tier off or continuous nothing is ever serialized, so every +// execution is "false" unconditionally and the window store is not consulted +// at all — even when a producer emits windows anyway (a leftover injection, a +// system-wide attach). Only PCSamplingSerialized routes through the evidence. +func TestOnlySerializedConsultsTheWindowStore(t *testing.T) { + for _, tier := range []PCSamplingTier{PCSamplingOff, PCSamplingContinuous} { + t.Run(tier.String(), func(t *testing.T) { + tl := NewTimeline(TimelineConfig{PCSampling: tier}) + emitBurst(t, tl, uint64(tierAPID), 1000, 9000) + require.NoError(t, tl.EmitExec(serializedExec("a", 2000, 3000))) + + snap := tl.Snapshot() + require.Len(t, snap.Executions, 1) + assert.Equal(t, tier, snap.PCSampling) + // Ingested and counted — the two ends must not go silently out of + // step — but the ANSWER is unconditional. + assert.Equal(t, uint64(2), snap.SamplingWindowsReceived) + assert.Equal(t, uint64(1), snap.ExecutionsNotSerialized) + assert.Zero(t, snap.ExecutionsSerialized) + assert.Zero(t, snap.ExecutionsSerializationUnknown) + assert.Equal(t, []string{"false"}, states(snap)) + assertSumIdentity(t, snap) + }) + } +} + +// The tier rides on the Snapshot rather than being inferred from the window +// counters, and this is the case an inference gets backwards: Tier A selected, +// not one window received. Inferring "no windows, so nothing was serialized" +// would mark a wholly perturbed run "false". +func TestTheSnapshotCarriesTheTierEvenWhenNoWindowArrived(t *testing.T) { + tl := tierATimeline() + require.NoError(t, tl.EmitExec(serializedExec("a", 2000, 3000))) + + snap := tl.Snapshot() + require.Len(t, snap.Executions, 1) + assert.Equal(t, PCSamplingSerialized, snap.PCSampling) + assert.Zero(t, snap.SamplingWindowsReceived) + assert.Equal(t, []string{"unknown"}, states(snap)) + assert.Equal(t, uint64(1), snap.ExecutionsSerializationUnknown) + assert.Zero(t, snap.ExecutionsNotSerialized, + "Tier A with no evidence is \"unknown\", never \"false\"") + assertSumIdentity(t, snap) +} + +// The Snapshot is an operator-facing artifact, so a serialized one says +// "serialized" rather than "2", and a document naming two tiers fails to +// decode rather than decoding to one of them. +func TestTheTierRoundTripsThroughJSONByName(t *testing.T) { + b, err := json.Marshal(Snapshot{PCSampling: PCSamplingSerialized}) + require.NoError(t, err) + assert.Contains(t, string(b), `"pc_sampling":"serialized"`) + + var back Snapshot + require.NoError(t, json.Unmarshal(b, &back)) + assert.Equal(t, PCSamplingSerialized, back.PCSampling) + + var bad PCSamplingTier + require.Error(t, bad.UnmarshalText([]byte("continuous,serialized"))) + require.Error(t, bad.UnmarshalText([]byte("nonsense"))) +} + +// -------------------------------------------------------- the standing warning + +// The warning exists for Tier A and for nothing else. A warning printed on an +// off or continuous run is a warning readers learn to skip, and then it is +// skipped on the run that mattered. +func TestTheStandingWarningIsTierAOnly(t *testing.T) { + assert.Nil(t, PCSamplingStandingWarning(PCSamplingOff)) + assert.Nil(t, PCSamplingStandingWarning(PCSamplingContinuous)) + assert.NotEmpty(t, PCSamplingStandingWarning(PCSamplingSerialized)) +} + +// ALL THREE perturbations, named. This is the requirement rather than a +// stylistic preference: an operator told only about the first will see +// gpu_serialized="true" on the GPU samples, conclude that the marked ones are +// the perturbed ones, and then trust an off-CPU profile whose synchronization +// waits are inflated by exactly this mechanism and carry no marking at all. +func TestTheStandingWarningNamesAllThreePerturbations(t *testing.T) { + lines := PCSamplingStandingWarning(PCSamplingSerialized) + require.NotEmpty(t, lines) + for _, l := range lines { + assert.True(t, strings.HasPrefix(l, PCSamplingWarningPrefix+": "), "line %q", l) + } + joined := strings.Join(lines, "\n") + + // 1. GPU kernel durations, and the label that marks them. + assert.Contains(t, joined, "GPU KERNEL DURATIONS INSIDE A BURST ARE INFLATED") + assert.Contains(t, joined, `gpu_serialized="true"`) + assert.Contains(t, joined, `must never be read as "false"`) + + // 2. The one an operator will otherwise get backwards: the CPU and + // off-CPU profiles are distorted and carry NO marking. + assert.Contains(t, joined, + "CPU AND OFF-CPU SAMPLES TAKEN DURING A BURST ARE DISTORTED AND CARRY NO MARKING AT ALL") + assert.Contains(t, joined, "ProjectExecutions") + assert.Contains(t, joined, "off-CPU profiling exists to measure") + + // 3. CUDA graphs, where the tier is unavailable rather than degraded. + assert.Contains(t, joined, "TIER A IS UNAVAILABLE WHERE CUDA GRAPHS ARE IN USE") + assert.Contains(t, joined, "downgrading silently to Tier B") + + // And that it says it stands, so a reader does not take it for a startup + // banner that has since stopped applying. + assert.Contains(t, joined, "stands for the whole run") +} + +// TestPCSamplingStandingWarningRenderedOutput prints the warning verbatim so +// the text an operator will actually read can be reviewed with +// `go test -run TestPCSamplingStandingWarningRenderedOutput -v ./gpu/`. +func TestPCSamplingStandingWarningRenderedOutput(t *testing.T) { + t.Log("\n" + strings.Join(PCSamplingStandingWarning(PCSamplingSerialized), "\n")) +} + +// ------------------------------------------------------- the two ends agree + +// The agent writes PERFAGENT_GPU_PC_SAMPLING and the producer reads it, from +// two parsers in two languages. A spelling that only one of them knows is a +// setting that silently does nothing — which for "serialized" means the +// operator gets an unperturbed profile they will read as a perturbed one, and +// for "off" would mean the opposite. +func TestTheShimAndTheAgentAgreeOnTheTierSpellings(t *testing.T) { + header, err := os.ReadFile(filepath.Join("..", "shim", "core", "pctier.h")) + require.NoError(t, err, "shim/core/pctier.h is the producer half of this setting") + src := string(header) + + for name, tier := range map[string]string{ + PCSamplingNameOff: "kOff", + PCSamplingNameContinuous: "kContinuous", + PCSamplingNameSerialized: "kSerialized", + } { + assert.Contains(t, src, `{"`+name+`", PCSamplingTier::`+tier+`}`, + "the producer must accept the name the agent writes") + } + for numeral, want := range map[string]PCSamplingTier{ + "0": PCSamplingOff, "1": PCSamplingContinuous, "2": PCSamplingSerialized, + } { + assert.Contains(t, src, `{"`+numeral+`", PCSamplingTier::k`+ + strings.ToUpper(want.String()[:1])+want.String()[1:]+`}`, + "the producer must accept the numeral an existing container spec carries") + parsed, err := ParsePCSamplingTier(numeral) + require.NoError(t, err) + assert.Equal(t, want, parsed, + "the agent must read back the numeral the producer accepts") + } +} diff --git a/gpu/timeline.go b/gpu/timeline.go index 271780a..96d396c 100644 --- a/gpu/timeline.go +++ b/gpu/timeline.go @@ -163,6 +163,18 @@ type Snapshot struct { PCJoin PCJoinStats `json:"pc_join,omitempty"` // ---- The serialization disclosure (Tier A). + // PCSampling is the tier this Timeline was configured with, carried on + // the Snapshot so every reader of one — JoinHealth, a serialized + // artifact, a test — can tell which of the three regimes below applies + // without being told separately. + // + // It is the DIFFERENCE between "Tier A was asked for and no window + // arrived" (every execution "unknown") and "Tier A was never asked for" + // (every execution "false"), which is why it rides here rather than being + // inferred from SamplingWindowsReceived: a Tier A run that received no + // window at all is exactly the case an inference would get backwards. + PCSampling PCSamplingTier `json:"pc_sampling,omitempty"` + // SamplingWindowsReceived is the CUMULATIVE number of PC-sampling burst // records accepted into the disclosure store, and SamplingWindowsHeld / // SamplingWindowsOpen are gauges of what it holds right now. A burst @@ -288,22 +300,27 @@ type TimelineConfig struct { // against. It is NOT silently skipped, which would make a missing store // look identical to a healthy run with no PC samples. Modules *ModuleStore - // SerializedSampling says that KERNEL_SERIALIZED PC sampling (Tier A) was - // SELECTED for this run. It is the agent's own configuration, not - // something inferred from the wire, and that is the point: "Tier A was - // asked for and no window arrived" and "Tier A was never asked for" are - // different facts with different answers, and only the agent knows which - // one holds. + // PCSampling is the GPU PC-sampling tier SELECTED for this run — the one + // setting, with its three values, that gpu/tier.go defines. It is the + // agent's own configuration, not something inferred from the wire, and + // that is the point: "Tier A was asked for and no window arrived" and + // "Tier A was never asked for" are different facts with different + // answers, and only the agent knows which one holds. // - // FALSE (the default) means every execution is gpu_serialized="false", - // unconditionally and correctly — with sampling off or in continuous - // collection nothing is ever serialized, so there is nothing to be unsure - // about. + // PCSamplingOff (the zero value) and PCSamplingContinuous both mean every + // execution is gpu_serialized="false", unconditionally and correctly — + // with sampling off or in continuous collection nothing is ever + // serialized, so there is nothing to be unsure about. // - // TRUE routes every execution through the window store, where the answer - // is "true", "false" or "unknown" depending on the evidence. Task 11 owns - // the setting that flips this; nothing here selects a tier. - SerializedSampling bool + // PCSamplingSerialized routes every execution through the window store, + // where the answer is "true", "false" or "unknown" depending on the + // evidence, and makes JoinHealth carry the standing perturbation warning + // for the whole run (PCSamplingStandingWarning). + // + // One field, not a tier plus a bool: two fields that can disagree about + // which tier is running is exactly how a profile ends up disclosing one + // thing and doing another. + PCSampling PCSamplingTier // MaxSamplingWindowsPerPID and MaxSamplingWindowPIDs bound the disclosure // store. Zero means defaultMaxSamplingWindowsPerPID / @@ -512,12 +529,13 @@ type Timeline struct { // drains on a timer — so classifying at ingest would mark a burst's own // executions "unknown" for the very reason the window exists. // - // serializedSampling is TimelineConfig.SerializedSampling, copied out at - // construction. When it is false this store is never consulted and every - // execution is "false", which is unconditionally correct in that - // configuration. - windows *windowStore - serializedSampling bool + // pcSampling is TimelineConfig.PCSampling, copied out at construction. + // Unless it is PCSamplingSerialized this store is never consulted and + // every execution is "false", which is unconditionally correct in the + // other two tiers. It also rides out on the Snapshot, so JoinHealth can + // stand its Tier A warning up on every render without a second channel. + windows *windowStore + pcSampling PCSamplingTier dropped TimelineDropStats } @@ -658,18 +676,18 @@ func NewTimeline(cfg TimelineConfig) *Timeline { devicesByPID: make(map[uint32]processDevices), - windows: newWindowStore(cfg.MaxSamplingWindowsPerPID, cfg.MaxSamplingWindowPIDs), - serializedSampling: cfg.SerializedSampling, + windows: newWindowStore(cfg.MaxSamplingWindowsPerPID, cfg.MaxSamplingWindowPIDs), + pcSampling: cfg.PCSampling, } } // EmitSamplingWindow records one PC-sampling burst. // -// It is accepted in EVERY configuration, not only when SerializedSampling is -// set. A producer that is emitting windows is a producer that is bursting, and -// dropping the evidence because the agent's own config disagrees would leave -// the two ends silently out of step. What SerializedSampling gates is the -// ANSWER, not the ingest. +// It is accepted in EVERY tier, not only in PCSamplingSerialized. A producer +// that is emitting windows is a producer that is bursting, and dropping the +// evidence because the agent's own config disagrees would leave the two ends +// silently out of step. What TimelineConfig.PCSampling gates is the ANSWER, +// not the ingest. func (t *Timeline) EmitSamplingWindow(w GPUSamplingWindow) error { t.mu.Lock() defer t.mu.Unlock() @@ -1268,11 +1286,11 @@ func (t *Timeline) Snapshot() Snapshot { // that have not arrived yet, and the next snapshot needs it), so the // alternative would be to clone every burst on every call. // - // serializedSampling FALSE takes the constant branch. With PC sampling off - // or in continuous collection nothing is ever serialized, so "false" is - // correct and unconditional and no store is consulted at all. + // Any tier but PCSamplingSerialized takes the constant branch. With PC + // sampling off or in continuous collection nothing is ever serialized, so + // "false" is correct and unconditional and no store is consulted at all. serialization := make([]SerializationState, len(execs)) - if t.serializedSampling { + if t.pcSampling == PCSamplingSerialized { for i, exec := range execs { serialization[i] = t.windows.classify(exec.Correlation.PID, exec.StartNs, exec.EndNs) } @@ -1283,6 +1301,7 @@ func (t *Timeline) Snapshot() Snapshot { } windowsReceived := t.windows.received windowsHeld, windowsOpen := t.windows.windows() + pcSampling := t.pcSampling t.mu.Unlock() // The heuristic's candidate set is built lazily - only once the loop @@ -1472,6 +1491,8 @@ func (t *Timeline) Snapshot() Snapshot { PendingModuleGroups: pendingModuleGroups, PCJoin: pcJoin.stats, + PCSampling: pcSampling, + SamplingWindowsReceived: windowsReceived, SamplingWindowsHeld: windowsHeld, SamplingWindowsOpen: windowsOpen, diff --git a/shim/Makefile b/shim/Makefile index 9c3e677..440dc5d 100644 --- a/shim/Makefile +++ b/shim/Makefile @@ -213,6 +213,14 @@ check-cubin-defer: core/cubin_defer_test.cc core/cubinqueue.h [ $$rc -eq 0 ] && echo "check-cubin-defer: OK - the compliant capture compiles, all 5 deferrals do not"; \ exit $$rc +# stub/pc_tier_test.cc is the "off means off" assertion, and it is built the +# same way probe_order_test is and for the same reason: it drives the real +# producer (stub/stub.cc with PERFAGENT_STUB_NO_MAIN) and reads its probe fires +# by patching the nops with int3, so it has to be the optimized build the gate +# runs. It asserts NEGATIVELY -- with the tier off, not one of the four +# PC-sampling probes may fire -- with the launch and exec probes trapped as +# controls so an inert producer cannot make it pass. +# # core/probe_args_test.cc and stub/probe_order_test.cc are the two tests here # built with -O2, and that is not a detail. probe_args_test checks that the # record a probe points at has actually been written to memory when the probe @@ -228,12 +236,13 @@ check-cubin-defer: core/cubin_defer_test.cc core/cubinqueue.h # _Static_asserts are the test. Compiling it only as C++ leaves the C11 spelling # unguarded -- which is how the header's validity broke earlier in this branch, # when a C++ translation unit included it for the first time. -test: check-cubin-defer core/batch_test.cc core/clock_test.cc core/cubin_test.cc core/cubinqueue_test.cc core/drain_test.cc core/pcdrain_test.cc core/burst_test.cc core/enroll_test.cc core/usdt_abi_test.c core/sampler_test.cc core/probe_args_test.cc stub/probe_order_test.cc stub/stub.cc $(CORE_SRC) +test: check-cubin-defer core/batch_test.cc core/clock_test.cc core/cubin_test.cc core/cubinqueue_test.cc core/drain_test.cc core/pcdrain_test.cc core/burst_test.cc core/pctier_test.cc core/enroll_test.cc core/usdt_abi_test.c core/sampler_test.cc core/probe_args_test.cc stub/probe_order_test.cc stub/pc_tier_test.cc stub/stub.cc $(CORE_SRC) $(CXX) -std=c++17 -pthread -I core -o /tmp/batch_test core/batch_test.cc core/batch.cc && /tmp/batch_test $(CXX) -std=c++17 -I core -o /tmp/clock_test core/clock_test.cc core/clock.cc && /tmp/clock_test $(CXX) -std=c++17 -pthread -I core -o /tmp/drain_test core/drain_test.cc core/drain.cc && /tmp/drain_test $(CXX) -std=c++17 -pthread -I core -o /tmp/pcdrain_test core/pcdrain_test.cc && /tmp/pcdrain_test $(CXX) -std=c++17 -Wall -Werror -pthread -I core -o /tmp/burst_test core/burst_test.cc && /tmp/burst_test + $(CXX) -std=c++17 -Wall -Werror -I core -o /tmp/pctier_test core/pctier_test.cc && /tmp/pctier_test $(CXX) -std=c++17 -pthread -I core -o /tmp/enroll_test core/enroll_test.cc core/enroll.cc && /tmp/enroll_test $(CXX) -std=c++17 -Wall -Werror -pthread -I core -o /tmp/cubin_test core/cubin_test.cc core/cubin.cc core/enroll.cc && /tmp/cubin_test $(CXX) -std=c++17 -Wall -Werror -pthread -I core -o /tmp/cubinqueue_test core/cubinqueue_test.cc core/cubinqueue.cc && /tmp/cubinqueue_test @@ -242,6 +251,8 @@ test: check-cubin-defer core/batch_test.cc core/clock_test.cc core/cubin_test.cc $(CXX) -std=c++17 -O2 -Wall -Werror -I core -o /tmp/probe_args_test core/probe_args_test.cc && /tmp/probe_args_test $(CXX) -std=c++17 -O2 -Wall -Werror -pthread -I core -DPERFAGENT_STUB_NO_MAIN \ -o /tmp/probe_order_test stub/probe_order_test.cc stub/stub.cc $(CORE_SRC) && /tmp/probe_order_test + $(CXX) -std=c++17 -O2 -Wall -Werror -pthread -I core -DPERFAGENT_STUB_NO_MAIN \ + -o /tmp/pc_tier_test stub/pc_tier_test.cc stub/stub.cc $(CORE_SRC) && /tmp/pc_tier_test # Not a prerequisite of `test`: a TSan build is ~10x slower and `test` is the # fast path. batch_test.cc's concurrent case asserts emitted + dropped == kAdds, diff --git a/shim/core/pctier.h b/shim/core/pctier.h new file mode 100644 index 0000000..4564d8c --- /dev/null +++ b/shim/core/pctier.h @@ -0,0 +1,146 @@ +// PC-sampling tier selection, producer side. +// +// One setting, three values, read from ONE environment variable: +// +// PERFAGENT_GPU_PC_SAMPLING = off | continuous | serialized (0 | 1 | 2) +// +// off (the default, and what an unset variable means) is OFF: nothing in the +// producer allocates a PC buffer, subscribes an extra CUPTI domain, calls a +// cupti PC-sampling entry point or fires a PC-sampling probe. Not "enabled but +// idle", not "enabled at a low rate". shim/stub/pc_tier_test.cc asserts that +// negatively, by patching the four PC-sampling probe sites with int3 and +// running the real producer with the tier off: not one of them may fire. +// +// Why the two tiers are exclusive, and why "both" is refused rather than +// resolved +// -------------------------------------------------------------------------- +// CUPTI's COLLECTION_MODE is a single per-CUcontext attribute, so a process +// could in principle run KERNEL_SERIALIZED on one context and CONTINUOUS on +// another. What rules that out is not CUPTI: it is that WHICH CONTEXT A GIVEN +// KERNEL LANDS ON IS THE APPLICATION'S CHOICE, NOT THE PROFILER'S. A "both" +// mode would emit one profile in which some kernels carry exact launch +// attribution and inflated durations while others carry inferred attribution +// and honest ones, split along an axis the operator can neither see nor +// control. That is worse than either tier alone. +// +// So a value naming two tiers is refused, loudly, and the producer falls +// CLOSED to off. It never picks one. "Last one wins" and "the cheaper one +// wins" are both decisions the operator did not make and cannot see in the +// output, which is the failure mode this whole file exists to prevent. +// +// The agent's own copy of these rules is gpu/tier.go, which is what writes the +// variable this file reads. The two must agree on the spellings; both accept +// the names and the numerals, and the agent writes the NAME. +#ifndef PERFAGENT_PCTIER_H +#define PERFAGENT_PCTIER_H + +#include +#include + +namespace perfagent { + +enum class PCSamplingTier : unsigned { + kOff = 0, + kContinuous = 1, // Tier B, CUPTI_PC_SAMPLING_COLLECTION_MODE_CONTINUOUS + kSerialized = 2, // Tier A, ..._KERNEL_SERIALIZED, duty-cycled +}; + +enum class PCTierParse { + kOK, + kUnknown, // a token that is not one of the three values + kNotExclusive, // more than one value named in the same setting +}; + +inline const char *pc_tier_name(PCSamplingTier t) { + switch (t) { + case PCSamplingTier::kOff: return "off"; + case PCSamplingTier::kContinuous: return "continuous"; + case PCSamplingTier::kSerialized: return "serialized"; + } + return "invalid"; +} + +// One token to a tier. The numeric spellings are accepted because this +// variable has been 0/1/2 since Task 6 and operators and container specs are +// already setting it that way; silently ignoring those would turn a configured +// Tier A run into a quiet off one. +inline bool pc_tier_token(const char *tok, size_t len, PCSamplingTier *out) { + struct { const char *name; PCSamplingTier tier; } kNames[] = { + {"off", PCSamplingTier::kOff}, + {"0", PCSamplingTier::kOff}, + {"continuous", PCSamplingTier::kContinuous}, + {"1", PCSamplingTier::kContinuous}, + {"serialized", PCSamplingTier::kSerialized}, + {"2", PCSamplingTier::kSerialized}, + }; + for (const auto &n : kNames) { + const size_t nl = strlen(n.name); + if (nl != len) continue; + size_t i = 0; + for (; i < len; i++) { + char c = tok[i]; + if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); + if (c != n.name[i]) break; + } + if (i == len) { *out = n.tier; return true; } + } + return false; +} + +// Parses the whole setting. On ANY error *out is kOff -- the producer falls +// closed, never into a tier nobody chose -- and `bad` receives the offending +// token (kUnknown) or the whole value (kNotExclusive) for the log line. +// +// A value naming two tiers is PARSED rather than rejected as syntax, because +// "both" has to be expressible for the refusal to be reachable at all. A +// parser that quietly took the first token of "continuous,serialized" would be +// exactly the silent pick this rule exists to prevent. +inline PCTierParse pc_tier_parse(const char *value, PCSamplingTier *out, + char *bad, size_t badlen) { + *out = PCSamplingTier::kOff; + if (bad && badlen) bad[0] = '\0'; + if (!value) return PCTierParse::kOK; + + bool seen = false; + PCSamplingTier first = PCSamplingTier::kOff; + const char *p = value; + while (*p) { + while (*p && (*p == ',' || *p == '+' || *p == ';' || *p == ' ' || + *p == '\t' || *p == '\n')) p++; + if (!*p) break; + const char *start = p; + while (*p && !(*p == ',' || *p == '+' || *p == ';' || *p == ' ' || + *p == '\t' || *p == '\n')) p++; + const size_t len = (size_t)(p - start); + + PCSamplingTier tier; + if (!pc_tier_token(start, len, &tier)) { + if (bad && badlen) { + const size_t n = len < badlen - 1 ? len : badlen - 1; + memcpy(bad, start, n); + bad[n] = '\0'; + } + *out = PCSamplingTier::kOff; + return PCTierParse::kUnknown; + } + if (!seen) { + first = tier; + seen = true; + } else if (tier != first) { + if (bad && badlen) { + const size_t vl = strlen(value); + const size_t n = vl < badlen - 1 ? vl : badlen - 1; + memcpy(bad, value, n); + bad[n] = '\0'; + } + *out = PCSamplingTier::kOff; + return PCTierParse::kNotExclusive; + } + } + *out = seen ? first : PCSamplingTier::kOff; + return PCTierParse::kOK; +} + +} // namespace perfagent + +#endif // PERFAGENT_PCTIER_H diff --git a/shim/core/pctier_test.cc b/shim/core/pctier_test.cc new file mode 100644 index 0000000..27f7310 --- /dev/null +++ b/shim/core/pctier_test.cc @@ -0,0 +1,127 @@ +// core/pctier.h: the producer half of tier selection. +// +// The parser is the one place where an operator's text becomes a decision that +// perturbs — or does not perturb — somebody else's production workload, so it +// is tested for what it REFUSES at least as hard as for what it accepts. Every +// refusal must land on kOff: a setting that cannot be read is never resolved +// to a guess, because "the cheaper tier" and "the first token" are both +// decisions the operator did not make and cannot see in the output. +// +// The agent's half is gpu/tier.go, which writes the variable this parses. The +// spelling table here and the one there are asserted to agree by +// TestTheShimAndTheAgentAgreeOnTheTierSpellings in gpu/tier_test.go, which +// reads this header. +#include "pctier.h" + +#include +#include +#include + +using perfagent::PCSamplingTier; +using perfagent::PCTierParse; + +static int g_bad; + +static void want(const char *value, PCTierParse expect_rc, PCSamplingTier expect_tier) { + PCSamplingTier got = PCSamplingTier::kSerialized; // poisoned, not kOff + char bad[96]; + const PCTierParse rc = perfagent::pc_tier_parse(value, &got, bad, sizeof(bad)); + if (rc != expect_rc || got != expect_tier) { + fprintf(stderr, "pctier_test: %-28s -> rc=%d tier=%s, want rc=%d tier=%s\n", + value ? value : "", (int)rc, perfagent::pc_tier_name(got), + (int)expect_rc, perfagent::pc_tier_name(expect_tier)); + g_bad = 1; + } +} + +int main() { + // The three values, in both spellings the ABI has ever used. The numerals + // are not legacy debt to be dropped: PERFAGENT_GPU_PC_SAMPLING has been + // 0/1/2 since Task 6 and container specs are already set that way, so + // ignoring them would turn a configured Tier A run into a silent off one. + want("off", PCTierParse::kOK, PCSamplingTier::kOff); + want("0", PCTierParse::kOK, PCSamplingTier::kOff); + want("continuous", PCTierParse::kOK, PCSamplingTier::kContinuous); + want("1", PCTierParse::kOK, PCSamplingTier::kContinuous); + want("serialized", PCTierParse::kOK, PCSamplingTier::kSerialized); + want("2", PCTierParse::kOK, PCSamplingTier::kSerialized); + + // Case and surrounding whitespace are an operator's typing, not a + // different setting. + want("SERIALIZED", PCTierParse::kOK, PCSamplingTier::kSerialized); + want(" Continuous ", PCTierParse::kOK, PCSamplingTier::kContinuous); + + // Unset and empty are the default, and the default is off. + want(nullptr, PCTierParse::kOK, PCSamplingTier::kOff); + want("", PCTierParse::kOK, PCSamplingTier::kOff); + want(" ", PCTierParse::kOK, PCSamplingTier::kOff); + + // Naming one tier twice is redundant, not contradictory. + want("continuous,continuous", PCTierParse::kOK, PCSamplingTier::kContinuous); + want("2 2", PCTierParse::kOK, PCSamplingTier::kSerialized); + + // BOTH TIERS. The rule this file exists for. Note the second ordering: + // a parser that took the first token would answer "continuous" for one of + // these and "serialized" for the other, which is the shape of a silent + // pick that looks correct in half the runs. + want("continuous,serialized", PCTierParse::kNotExclusive, PCSamplingTier::kOff); + want("serialized,continuous", PCTierParse::kNotExclusive, PCSamplingTier::kOff); + want("1+2", PCTierParse::kNotExclusive, PCSamplingTier::kOff); + want("serialized off", PCTierParse::kNotExclusive, PCSamplingTier::kOff); + + // Unknown, including the near-misses an operator actually types. + want("nonsense", PCTierParse::kUnknown, PCSamplingTier::kOff); + want("3", PCTierParse::kUnknown, PCSamplingTier::kOff); + want("true", PCTierParse::kUnknown, PCSamplingTier::kOff); + want("on", PCTierParse::kUnknown, PCSamplingTier::kOff); + want("serialised", PCTierParse::kUnknown, PCSamplingTier::kOff); + // A good token beside a bad one is still a refusal, not the good token. + want("continuous,nonsense", PCTierParse::kUnknown, PCSamplingTier::kOff); + want("nonsense,continuous", PCTierParse::kUnknown, PCSamplingTier::kOff); + + // The offending text reaches the log, because a refusal that does not say + // WHAT it refused makes the operator guess at their own typo. + { + PCSamplingTier got; + char bad[96]; + assert(perfagent::pc_tier_parse("nonsense", &got, bad, sizeof(bad)) == + PCTierParse::kUnknown); + assert(strcmp(bad, "nonsense") == 0); + assert(perfagent::pc_tier_parse("continuous,serialized", &got, bad, sizeof(bad)) == + PCTierParse::kNotExclusive); + assert(strcmp(bad, "continuous,serialized") == 0); + } + + // A token longer than the log buffer must truncate, not overrun it. The + // producer is inside somebody else's process; a stack smash here is their + // crash, not ours. + { + char huge[512]; + memset(huge, 'x', sizeof(huge) - 1); + huge[sizeof(huge) - 1] = '\0'; + PCSamplingTier got = PCSamplingTier::kSerialized; + char bad[16]; + assert(perfagent::pc_tier_parse(huge, &got, bad, sizeof(bad)) == PCTierParse::kUnknown); + assert(got == PCSamplingTier::kOff); + assert(strlen(bad) == sizeof(bad) - 1); + } + + // A null `bad` buffer is a supported call: the adapter's report path has + // nowhere to put it. + { + PCSamplingTier got = PCSamplingTier::kSerialized; + assert(perfagent::pc_tier_parse("nonsense", &got, nullptr, 0) == PCTierParse::kUnknown); + assert(got == PCSamplingTier::kOff); + } + + // The names round-trip, and an out-of-range tier does NOT render as "off". + // A value that fell out of a bad cast must not read as the safe default — + // "off" is exactly the answer nobody would investigate. + assert(strcmp(perfagent::pc_tier_name(PCSamplingTier::kOff), "off") == 0); + assert(strcmp(perfagent::pc_tier_name(PCSamplingTier::kContinuous), "continuous") == 0); + assert(strcmp(perfagent::pc_tier_name(PCSamplingTier::kSerialized), "serialized") == 0); + assert(strcmp(perfagent::pc_tier_name((PCSamplingTier)7), "invalid") == 0); + + if (!g_bad) printf("pctier_test: ok\n"); + return g_bad; +} diff --git a/shim/nvidia/cupti_adapter.cc b/shim/nvidia/cupti_adapter.cc index 0f0a770..85af092 100644 --- a/shim/nvidia/cupti_adapter.cc +++ b/shim/nvidia/cupti_adapter.cc @@ -18,6 +18,7 @@ #include "enroll.h" #include "kernelnames.h" #include "pcdrain.h" +#include "pctier.h" #include "sampler.h" #include "usdt_abi.h" #include "usdt_probe.h" @@ -356,18 +357,20 @@ unsigned env_uint(const char *name, unsigned dflt); // --------------------------------------------------------- PC sampling // // Two collection tiers, mutually exclusive, both OFF BY DEFAULT. Nothing -// below runs, allocates or calls CUPTI unless PERFAGENT_GPU_PC_SAMPLING is -// set, so merging either of them cannot degrade a profiler that is shipping -// today. +// below runs, allocates or calls CUPTI unless PERFAGENT_GPU_PC_SAMPLING names +// a tier, so merging either of them cannot degrade a profiler that is shipping +// today. OFF MEANS OFF: no PC buffer, no extra CUPTI domain, no cupti PC entry +// point and no PC-sampling probe fire. See core/pctier.h for the parse and for +// why naming both tiers is refused rather than resolved. // -// Tier B --- PERFAGENT_GPU_PC_SAMPLING=1, CUPTI_PC_SAMPLING_COLLECTION_MODE_ -// CONTINUOUS. Kernels are NOT serialized in this mode, which is the only +// Tier B --- PERFAGENT_GPU_PC_SAMPLING=continuous (1), +// CUPTI_PC_SAMPLING_COLLECTION_MODE_CONTINUOUS. Kernels are NOT serialized in this mode, which is the only // reason it is a candidate for always-on profiling; the cost is that every PC // record's correlationId is zero, so a PC sample joins to a kernel through its // module and never to the launch that issued it. // -// Tier A --- PERFAGENT_GPU_PC_SAMPLING=2, CUPTI_PC_SAMPLING_COLLECTION_MODE_ -// KERNEL_SERIALIZED with ENABLE_START_STOP_CONTROL, duty-cycled by +// Tier A --- PERFAGENT_GPU_PC_SAMPLING=serialized (2), +// CUPTI_PC_SAMPLING_COLLECTION_MODE_KERNEL_SERIALIZED with ENABLE_START_STOP_CONTROL, duty-cycled by // core/burst.h. CUPTI populates correlationId on every PC record here, so a // sample joins to a launch --- and therefore to a CPU stack --- exactly. The // price is that every kernel that runs while a burst is open runs SERIALIZED, @@ -442,11 +445,17 @@ constexpr size_t kPCDefaultCollectNumPcs = 2048; // else's process; hitting the bound is counted rather than retried forever. constexpr unsigned kPCMaxDrainRounds = 64; -bool g_pc_enabled = false; // PERFAGENT_GPU_PC_SAMPLING != 0 -// Tier A. Set from the SAME variable as g_pc_enabled (value 2), so the two -// tiers are mutually exclusive by construction rather than by a check that -// can be forgotten --- which is what Task 11's tier selection wants anyway. -bool g_pc_tier_a = false; +// The selected tier, and the two booleans derived from it. All three come +// from ONE parse of ONE variable (core/pctier.h), so the tiers are mutually +// exclusive by construction rather than by a check that can be forgotten. +perfagent::PCSamplingTier g_pc_tier = perfagent::PCSamplingTier::kOff; +bool g_pc_enabled = false; // g_pc_tier != kOff +bool g_pc_tier_a = false; // g_pc_tier == kSerialized +// A setting that named no tier we know, or named two. Counted rather than only +// logged: a startup log line in somebody else's process is routinely swallowed +// by whatever captures its stderr, and "PC sampling produced nothing" and "PC +// sampling was refused at startup" must not look the same in the report. +std::atomic g_pc_tier_refused{0}; uint32_t g_pc_period = 0; // the exponent actually in force size_t g_pc_collect_num_pcs = kPCDefaultCollectNumPcs; size_t g_pc_scratch_bytes = 0; // 0 = CUPTI's default @@ -1514,7 +1523,9 @@ void report(const char *why) { (unsigned long long)g_exec_from_graph.load(), (unsigned long long)g_multi_device.load(), g_devices_seen.size()); if (!g_pc_enabled) { - logf("perfagent-cupti: pc_sampling=off (set PERFAGENT_GPU_PC_SAMPLING=1)\n"); + logf("perfagent-cupti: pc_sampling=off tier_refused=%llu " + "(set PERFAGENT_GPU_PC_SAMPLING=continuous or =serialized)\n", + (unsigned long long)g_pc_tier_refused.load()); return; } // Every drop class and every context-enable failure has a counter here, @@ -1543,7 +1554,7 @@ void report(const char *why) { (unsigned long long)g_tier_a_graph_refused.load(), g_burst && g_burst->sampling() ? 1 : 0); } - logf("perfagent-cupti: pc %s period=%u(=%u cycles) stall_reasons=%zu " + logf("perfagent-cupti: pc %s tier=%s period=%u(=%u cycles) stall_reasons=%zu " "ctx_seen=%llu ctx_enabled=%llu ctx_enable_failed=%llu " "ctx_destroyed=%llu ctx_disable_failed=%llu " "pc_records=%llu pcs=%llu pc_batch_dropped=%llu pc_unattached=%llu " @@ -1556,7 +1567,7 @@ void report(const char *why) { "graph_execs=%llu multi_device=%llu finalize_seen=%llu " "finalize_contended=%llu " "config_emitted=%llu config_no_device=%llu sm_count=%u clock_hz=%llu\n", - why, g_pc_period, + why, perfagent::pc_tier_name(g_pc_tier), g_pc_period, (g_pc_period >= kPCPeriodMin && g_pc_period <= kPCPeriodMax) ? (1u << g_pc_period) : 0u, g_num_stall_reasons, (unsigned long long)g_ctx_seen.load(), @@ -1832,27 +1843,45 @@ extern "C" __attribute__((visibility("default"))) int InitializeInjection(void) g_replay = new perfagent::ReplayLog(); - // Tier B. OFF unless asked for, and read before cuptiSubscribe so the + // The tier. OFF unless asked for, and read before cuptiSubscribe so the // RESOURCE callback cannot reach a half-initialized PC path. // - // Tier A (KERNEL_SERIALIZED) is a separate task and is not reachable from - // here; tier selection is another. This flag is the whole of the switch - // for now, and its default is what keeps a merge from changing what a - // shipping profiler does. - // 0/unset = off, 1 = Tier B (CONTINUOUS), 2 = Tier A (KERNEL_SERIALIZED, - // duty-cycled). One variable, so the two tiers cannot both be selected -- - // they configure the same per-context CUPTI attribute and "both" would - // produce a profile whose attribution quality varied by an axis the - // operator cannot see. Task 11 replaces this with a named setting and a - // CLI flag; the exclusivity is already structural here. - const unsigned pc_mode = env_uint("PERFAGENT_GPU_PC_SAMPLING", 0); - g_pc_enabled = pc_mode != 0; - g_pc_tier_a = pc_mode == 2; - if (pc_mode > 2) { - logf("perfagent-cupti: PERFAGENT_GPU_PC_SAMPLING=%u is not a tier " - "(1=continuous, 2=serialized); treating it as 1\n", pc_mode); - g_pc_tier_a = false; + // off | continuous | serialized (0 | 1 | 2), one variable, so the two + // tiers cannot both be selected: they configure the same per-CUcontext + // COLLECTION_MODE attribute, and "both" would produce a profile whose + // attribution quality varied along an axis the operator can neither see + // nor control. See core/pctier.h; the agent's half is gpu/tier.go. + // + // Every refusal below falls CLOSED to off and says so at length. It never + // picks a tier: an unreadable setting resolved to "the cheaper one" is a + // decision the operator did not make and cannot see in the output. + { + const char *raw = getenv("PERFAGENT_GPU_PC_SAMPLING"); + char bad[96]; + switch (perfagent::pc_tier_parse(raw, &g_pc_tier, bad, sizeof(bad))) { + case perfagent::PCTierParse::kOK: + break; + case perfagent::PCTierParse::kUnknown: + g_pc_tier_refused.fetch_add(1, std::memory_order_relaxed); + logf("perfagent-cupti: PERFAGENT_GPU_PC_SAMPLING=\"%s\" names \"%s\", which is " + "not a tier. The three values are off, continuous and serialized (0, 1, 2). " + "PC SAMPLING IS OFF for this process -- a setting that cannot be read is not " + "resolved to a guess.\n", raw ? raw : "", bad); + break; + case perfagent::PCTierParse::kNotExclusive: + g_pc_tier_refused.fetch_add(1, std::memory_order_relaxed); + logf("perfagent-cupti: PERFAGENT_GPU_PC_SAMPLING=\"%s\" names MORE THAN ONE TIER. " + "They are mutually exclusive and the selection is process-wide: " + "COLLECTION_MODE is a single per-CUcontext CUPTI attribute, and which context " + "a kernel lands on is the application's choice rather than the profiler's, so " + "\"both\" would produce one profile whose attribution quality varied along an " + "axis the operator can neither see nor control. PC SAMPLING IS OFF for this " + "process; name exactly one of off, continuous, serialized.\n", bad); + break; + } } + g_pc_enabled = g_pc_tier != perfagent::PCSamplingTier::kOff; + g_pc_tier_a = g_pc_tier == perfagent::PCSamplingTier::kSerialized; if (g_pc_enabled) { // 0 = leave CUPTI's own SM-count-derived default, which is then read // back so gpu_config_v1 reports the real period. Anything outside diff --git a/shim/stub/pc_tier_test.cc b/shim/stub/pc_tier_test.cc new file mode 100644 index 0000000..34e4825 --- /dev/null +++ b/shim/stub/pc_tier_test.cc @@ -0,0 +1,309 @@ +// Proves that PC-sampling tier "off" means OFF at the wire: with +// PERFAGENT_GPU_PC_SAMPLING unset or naming no tier, NOT ONE PC-sampling probe +// fires -- however loudly the stub's own PC knobs are turned up. +// +// Why this is a probe test and not a counter test +// ----------------------------------------------- +// "off results in no PC-sampling calls at all" is a claim about what leaves +// the producer, and every cheaper way of checking it is a way of checking +// something else. A counter says what the producer THINKS it emitted. A +// consumer-side assertion says what survived a ringbuf. Reading the env in a +// unit test says what the parser returned. Seventeen defects on this project +// have been counters and checks reading green exactly when things were worst, +// so the assertion here is made where a uprobe would make it: at the probe +// site itself, in the optimized binary the gate runs. +// +// How it sees the wire without any privilege +// ------------------------------------------ +// The same trick core/probe_args_test.cc and stub/probe_order_test.cc use: +// read our own .note.stapsdt to find the probe sites, patch their one-byte +// nops with int3 (which is all a uprobe does), and count the traps in the +// SIGTRAP handler. No CAP_BPF, no consumer, no GPU. +// +// The four probes trapped are the ones PC sampling owns: +// +// gpu_pc_sample_batch_v1 the samples themselves +// gpu_stall_reason_map_v1 the device's stall table +// gpu_config_v1 the sampling configuration in force +// gpu_sampling_window_v1 Tier A's serialization disclosure +// +// Non-vacuity is the other half of the test and it is asserted in three ways, +// because an "off" pass that trapped nothing would be equally green if the +// producer had simply failed to run: +// +// 1. the launch and exec probes ARE trapped too, and must fire in every +// pass including the off ones; +// 2. pass 2 (continuous) must fire the sample, stall and config probes, so +// the trapped sites are demonstrably reachable in this very binary; +// 3. pass 3 (serialized) must fire the window probe, likewise. +// +// A fourth and fifth pass cover the refusals: an unknown value and a value +// naming BOTH tiers must each fall closed to off, not to a tier nobody chose. +#include "usdt_abi.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if !defined(__x86_64__) +int main() { + fprintf(stderr, "pc_tier_test: skipped, not x86-64\n"); + return 0; +} +#else + +#include + +extern "C" void perfagent_stub_run(unsigned launches, unsigned period_us, + unsigned sample_period); + +// stub.cc's probe semaphores. Hidden visibility, so this only links because +// the producer and the test are one binary -- which is the point: nothing here +// asks the producer to behave differently for a test. +#define SEM(name) \ + extern "C" unsigned short perfagent_##name##_semaphore __attribute__((visibility("hidden"))) +SEM(gpu_launch_v1); +SEM(gpu_exec_v1); +SEM(gpu_launch_sampled_v1); +SEM(gpu_kernel_name_v1); +SEM(gpu_module_load_v1); +SEM(gpu_pc_sample_batch_v1); +SEM(gpu_stall_reason_map_v1); +SEM(gpu_config_v1); +SEM(gpu_dropped_v1); +SEM(gpu_sampling_window_v1); +#undef SEM + +extern "C" char perfagent_stapsdt_base_sym __asm__("_.stapsdt.base"); + +// ------------------------------------------------------------ the recorder + +enum { + kProbePCSample, + kProbeStallMap, + kProbeConfig, + kProbeWindow, + // The two controls. They are not PC-sampling probes; they are here so an + // "off" pass that fired nothing at all fails instead of passing. + kProbeLaunch, + kProbeExec, + kProbeCount, +}; + +static const char *const kProbeName[kProbeCount] = { + "gpu_pc_sample_batch_v1", "gpu_stall_reason_map_v1", "gpu_config_v1", + "gpu_sampling_window_v1", "gpu_launch_v1", "gpu_exec_v1", +}; + +// The first four are the ones "off" must silence. Kept as a count rather than +// a second list so the two cannot drift apart. +static const int kPCProbes = 4; + +static uintptr_t g_probe_site[kProbeCount]; +static unsigned long g_fires[kProbeCount]; +static unsigned long g_records[kProbeCount]; + +static void on_trap(int, siginfo_t *, void *ucv) { + ucontext_t *uc = (ucontext_t *)ucv; + // int3 leaves %rip one byte past the patched nop, which is where the nop + // itself would have left it: returning resumes correctly with no + // single-stepping and no byte to restore. + const uintptr_t site = (uintptr_t)uc->uc_mcontext.gregs[REG_RIP] - 1; + const unsigned long count = (unsigned long)uc->uc_mcontext.gregs[REG_RSI]; + for (int i = 0; i < kProbeCount; i++) { + if (site != g_probe_site[i]) continue; + __atomic_add_fetch(&g_fires[i], 1, __ATOMIC_SEQ_CST); + __atomic_add_fetch(&g_records[i], count, __ATOMIC_SEQ_CST); + return; + } +} + +// --------------------------------------------------------------- the notes + +// probe_address returns the run-time address of the named probe's nop, read +// out of this binary's own .note.stapsdt -- the same note internal/usdt parses +// and the same one the kernel is pointed at. +static uintptr_t probe_address(const char *want) { + int fd = open("/proc/self/exe", O_RDONLY); + assert(fd >= 0 && "open /proc/self/exe"); + struct stat st; + assert(fstat(fd, &st) == 0); + const uint8_t *m = (const uint8_t *)mmap(nullptr, (size_t)st.st_size, PROT_READ, + MAP_PRIVATE, fd, 0); + assert(m != MAP_FAILED); + close(fd); + + const Elf64_Ehdr *eh = (const Elf64_Ehdr *)m; + const Elf64_Shdr *sh = (const Elf64_Shdr *)(m + eh->e_shoff); + const char *shstr = (const char *)(m + sh[eh->e_shstrndx].sh_offset); + + uintptr_t found = 0; + for (unsigned i = 0; i < eh->e_shnum && !found; i++) { + if (sh[i].sh_type != SHT_NOTE) continue; + if (strcmp(shstr + sh[i].sh_name, ".note.stapsdt") != 0) continue; + const uint8_t *p = m + sh[i].sh_offset; + const uint8_t *end = p + sh[i].sh_size; + while (p + sizeof(Elf64_Nhdr) <= end) { + const Elf64_Nhdr *nh = (const Elf64_Nhdr *)p; + const uint8_t *desc = p + sizeof(Elf64_Nhdr) + ((nh->n_namesz + 3) & ~3u); + uint64_t pc, base; + memcpy(&pc, desc, 8); + memcpy(&base, desc + 8, 8); + const char *provider = (const char *)(desc + 24); + const char *name = provider + strlen(provider) + 1; + if (strcmp(provider, "perfagent") == 0 && strcmp(name, want) == 0) { + found = (uintptr_t)pc + + ((uintptr_t)&perfagent_stapsdt_base_sym - (uintptr_t)base); + break; + } + p = desc + ((nh->n_descsz + 3) & ~3u); + } + } + munmap((void *)m, (size_t)st.st_size); + return found; +} + +// Become the uprobe: replace the probe's one-byte nop with int3. The mapping +// is private, so this is a copy-on-write of our own text. +static void patch(uintptr_t probe) { + const long pagesize = sysconf(_SC_PAGESIZE); + void *page = (void *)(probe & ~(uintptr_t)(pagesize - 1)); + assert(mprotect(page, (size_t)pagesize * 2, PROT_READ | PROT_WRITE | PROT_EXEC) == 0); + assert(*(volatile uint8_t *)probe == 0x90 && "probe site is not the expected nop"); + *(volatile uint8_t *)probe = 0xCC; + assert(mprotect(page, (size_t)pagesize * 2, PROT_READ | PROT_EXEC) == 0); +} + +// ---------------------------------------------------------------- the passes + +// want: -1 "must not fire", 1 "must fire at least once", 0 "do not care". +struct Expect { + int pc_sample, stall_map, config, window; +}; + +static int run_pass(const char *what, const char *tier_value, Expect want) { + memset(g_fires, 0, sizeof(g_fires)); + memset(g_records, 0, sizeof(g_records)); + if (tier_value) setenv("PERFAGENT_GPU_PC_SAMPLING", tier_value, 1); + else unsetenv("PERFAGENT_GPU_PC_SAMPLING"); + + // 64 launches, no sleep: enough to flush a full 32-record launch batch + // twice, so the control probes cannot fail to fire for want of volume. + perfagent_stub_run(64, 0, 1); + + const int expect[kPCProbes] = {want.pc_sample, want.stall_map, want.config, want.window}; + int bad = 0; + for (int i = 0; i < kPCProbes; i++) { + const unsigned long fires = g_fires[i]; + if (expect[i] < 0 && fires != 0) { + fprintf(stderr, + "pc_tier_test: %s: %s FIRED %lu times (%lu records) with the tier off.\n" + " \"off\" must mean no PC-sampling call at all -- not enabled-but-idle,\n" + " not enabled-at-a-low-rate. A producer that emits a PC-sampling record\n" + " the operator did not ask for is sampling a workload nobody consented\n" + " to have sampled, and in Tier A's case perturbing it.\n", + what, kProbeName[i], fires, g_records[i]); + bad = 1; + } + if (expect[i] > 0 && fires == 0) { + fprintf(stderr, + "pc_tier_test: %s: %s never fired, so the negative passes above prove\n" + " nothing -- this probe site is not reachable in this binary at all.\n", + what, kProbeName[i]); + bad = 1; + } + } + + // The controls, on EVERY pass. Without them an "off" pass would be just as + // green if perfagent_stub_run had returned immediately. + if (!g_fires[kProbeLaunch] || !g_fires[kProbeExec]) { + fprintf(stderr, + "pc_tier_test: %s: the producer emitted no launch (%lu) or exec (%lu) record,\n" + " so it did not run and every assertion above passed vacuously.\n", + what, g_fires[kProbeLaunch], g_fires[kProbeExec]); + bad = 1; + } + + if (!bad) { + printf("pc_tier_test: %s ok - pc_sample=%lu stall_map=%lu config=%lu window=%lu " + "(launch=%lu exec=%lu)\n", + what, g_fires[kProbePCSample], g_fires[kProbeStallMap], + g_fires[kProbeConfig], g_fires[kProbeWindow], g_fires[kProbeLaunch], + g_fires[kProbeExec]); + } + return bad; +} + +int main() { + // The rendezvous is a consumer-side service and there is no consumer here; + // disabling it keeps this test from spending its budget discovering that + // (shim/core/enroll.h). + setenv("PERFAGENT_GPU_ENROLL_TIMEOUT_MS", "0", 1); + // The stub's own PC knobs, turned UP and left up for every pass including + // the off ones. That is the whole point: the tier must silence the + // producer even when everything else is asking it to speak. + setenv("PERFAGENT_STUB_PC_SAMPLES", "128", 1); + setenv("PERFAGENT_STUB_SAMPLING_WINDOWS", "4", 1); + + for (int i = 0; i < kProbeCount; i++) { + g_probe_site[i] = probe_address(kProbeName[i]); + if (!g_probe_site[i]) { + fprintf(stderr, "pc_tier_test: no .note.stapsdt entry for %s\n", kProbeName[i]); + return 1; + } + } + + struct sigaction sa {}; + sa.sa_sigaction = on_trap; + sa.sa_flags = SA_SIGINFO; + sigemptyset(&sa.sa_mask); + assert(sigaction(SIGTRAP, &sa, nullptr) == 0); + for (int i = 0; i < kProbeCount; i++) patch(g_probe_site[i]); + + // Arm every semaphore. The producer takes a different path when a probe is + // unattached (Batch::add counts and discards), and a test that left the PC + // semaphores at zero would be proving that the semaphore gate works, not + // that the tier gate does. + perfagent_gpu_launch_v1_semaphore = 1; + perfagent_gpu_exec_v1_semaphore = 1; + perfagent_gpu_launch_sampled_v1_semaphore = 1; + perfagent_gpu_kernel_name_v1_semaphore = 1; + perfagent_gpu_module_load_v1_semaphore = 1; + perfagent_gpu_pc_sample_batch_v1_semaphore = 1; + perfagent_gpu_stall_reason_map_v1_semaphore = 1; + perfagent_gpu_config_v1_semaphore = 1; + perfagent_gpu_dropped_v1_semaphore = 1; + perfagent_gpu_sampling_window_v1_semaphore = 1; + + const Expect kSilent = {-1, -1, -1, -1}; + int bad = 0; + // 1. Unset. The shipping default, and the one an operator gets by doing + // nothing at all. + bad |= run_pass("tier unset", nullptr, kSilent); + // 2. Explicitly off, both spellings the parser accepts. + bad |= run_pass("tier=off", "off", kSilent); + bad |= run_pass("tier=0", "0", kSilent); + // 3. Tier B. The positive control for three of the four probes -- and the + // negative one for the window probe, since a CONTINUOUS producer that + // announced a serialization window would be claiming a perturbation it + // did not cause. + bad |= run_pass("tier=continuous", "continuous", Expect{1, 1, 1, -1}); + // 4. Tier A. The positive control for the window probe. + bad |= run_pass("tier=serialized", "serialized", Expect{1, 1, 1, 1}); + // 5. The refusals. Both must fall CLOSED to off. A parser that took the + // first token of "continuous,serialized" would turn a refused setting + // into a tier nobody chose, and would look exactly like a correct run. + bad |= run_pass("tier=nonsense", "nonsense", kSilent); + bad |= run_pass("tier=continuous,serialized", "continuous,serialized", kSilent); + bad |= run_pass("tier=serialized,continuous", "serialized,continuous", kSilent); + return bad; +} +#endif diff --git a/shim/stub/stub.cc b/shim/stub/stub.cc index e8388d9..cec84aa 100644 --- a/shim/stub/stub.cc +++ b/shim/stub/stub.cc @@ -8,6 +8,7 @@ #include "drain.h" #include "enroll.h" #include "kernelnames.h" +#include "pctier.h" #include "sampler.h" #include "usdt_abi.h" #include "usdt_probe.h" @@ -46,17 +47,28 @@ PERFAGENT_USDT_EMITTER(gpu_kernel_name_v1, 272); PERFAGENT_USDT_EMITTER(gpu_module_load_v1, 40); // The PC-sampling records, so the whole Tier B decode path -- PC samples, the // stall-reason map, the config record and every producer-side drop class -- -// can be driven on a machine with no GPU. Off unless PERFAGENT_STUB_PC_SAMPLES -// asks for them, so the existing gates and probe_order_test see exactly the -// wire they saw before. +// can be driven on a machine with no GPU. Off unless PERFAGENT_GPU_PC_SAMPLING +// selects a tier AND PERFAGENT_STUB_PC_SAMPLES asks for a count, so the +// existing gates and probe_order_test see exactly the wire they saw before. +// +// The TIER gate is the outer one, and it is not decoration. The stub is the +// producer the agent hands its selection to on a machine with no GPU, so "off +// means off" is only assertable here if this producer honours the same setting +// the CUPTI adapter does -- from the same parser, core/pctier.h. +// stub/pc_tier_test.cc patches these four probe sites with int3 and requires +// that with the tier off NOT ONE of them fires, while the launch and exec +// probes still do (so the assertion cannot pass by the producer being inert). PERFAGENT_USDT_EMITTER(gpu_pc_sample_batch_v1, 40); PERFAGENT_USDT_EMITTER(gpu_stall_reason_map_v1, 136); PERFAGENT_USDT_EMITTER(gpu_config_v1, 24); PERFAGENT_USDT_EMITTER(gpu_dropped_v1, 16); -// Tier A's disclosure. PERFAGENT_STUB_SAMPLING_WINDOWS= synthesizes n -// KERNEL_SERIALIZED bursts bracketing the executions this stub emits, so the -// consumer's window -> execution intersection and all three gpu_serialized -// values are reachable on a machine with no GPU. +// Tier A's disclosure. With PERFAGENT_GPU_PC_SAMPLING=serialized, +// PERFAGENT_STUB_SAMPLING_WINDOWS= synthesizes n KERNEL_SERIALIZED bursts +// bracketing the executions this stub emits, so the consumer's window -> +// execution intersection and all three gpu_serialized values are reachable on +// a machine with no GPU. In any other tier no window is emitted at all: a +// window record is Tier A's own disclosure, and a producer that emitted one +// while running CONTINUOUS would be claiming a perturbation it did not cause. PERFAGENT_USDT_EMITTER(gpu_sampling_window_v1, 24); // The synthetic stall table. Real names from GA102 rather than invented ones: @@ -190,6 +202,31 @@ static unsigned stub_capture_modules(perfagent::CubinQueue &q) { // whatever process it's injected into. extern "C" __attribute__((visibility("default"))) void perfagent_stub_run(unsigned launches, unsigned period_us, unsigned sample_period) { + // The tier, read FIRST and from the same parser the CUPTI adapter uses + // (core/pctier.h), because everything PC-sampling below is gated on it and + // "off means off" is a claim about this producer as much as about that + // one. Any unreadable or non-exclusive setting falls closed to off and + // says so; it never picks a tier. + perfagent::PCSamplingTier tier = perfagent::PCSamplingTier::kOff; + { + const char *raw = getenv("PERFAGENT_GPU_PC_SAMPLING"); + char bad[96]; + switch (perfagent::pc_tier_parse(raw, &tier, bad, sizeof(bad))) { + case perfagent::PCTierParse::kOK: + break; + case perfagent::PCTierParse::kUnknown: + fprintf(stderr, "stub: PERFAGENT_GPU_PC_SAMPLING=\"%s\" names \"%s\", which is not " + "a tier (off, continuous, serialized); PC SAMPLING IS OFF\n", + raw ? raw : "", bad); + break; + case perfagent::PCTierParse::kNotExclusive: + fprintf(stderr, "stub: PERFAGENT_GPU_PC_SAMPLING=\"%s\" names MORE THAN ONE TIER; " + "they are mutually exclusive and process-wide, so PC SAMPLING IS " + "OFF. Name exactly one of off, continuous, serialized\n", bad); + break; + } + } + // The #49 startup rendezvous, before the first launch and therefore // before the first probe: wait for the consumer to install this process's // CFI tables, so the kernel-side walk of every sampled launch has them. @@ -236,7 +273,9 @@ perfagent_stub_run(unsigned launches, unsigned period_us, unsigned sample_period // hard-exit case, where every execution from that start_ns onward is // gpu_serialized="unknown" and must never read "false". const char *winenv = getenv("PERFAGENT_STUB_SAMPLING_WINDOWS"); - const unsigned sampling_windows = (winenv && *winenv) ? (unsigned)atoi(winenv) : 0; + const unsigned sampling_windows = + (tier == perfagent::PCSamplingTier::kSerialized && winenv && *winenv) + ? (unsigned)atoi(winenv) : 0; const char *openenv = getenv("PERFAGENT_STUB_SAMPLING_WINDOW_OPEN"); const bool leave_last_open = openenv && *openenv && atoi(openenv) != 0; uint64_t first_exec_ns = 0, last_exec_ns = 0; @@ -246,11 +285,19 @@ perfagent_stub_run(unsigned launches, unsigned period_us, unsigned sample_period perfagent::CubinQueue cubins; const unsigned cubin_timeout_ms = perfagent::cubin_timeout_ms(2000); - // Tier B is off unless asked for, here as in the adapter. The count is the - // number of synthetic (PC, stall reason) records -- one record per pair, - // which is what the fixed-size ABI record forces. + // PC sampling is off unless a TIER asks for it, here as in the adapter. + // PERFAGENT_STUB_PC_SAMPLES then says how many synthetic (PC, stall + // reason) records to emit -- one record per pair, which is what the + // fixed-size ABI record forces. + // + // The tier is the OUTER gate and the stub knob the inner one. With the + // tier off this producer must make no PC-sampling call at all no matter + // what the stub knobs say, which is precisely the claim + // stub/pc_tier_test.cc asserts by trapping the probe sites. const char *pcenv = getenv("PERFAGENT_STUB_PC_SAMPLES"); - const unsigned pc_samples = (pcenv && *pcenv) ? (unsigned)atoi(pcenv) : 0; + const unsigned pc_samples = + (tier != perfagent::PCSamplingTier::kOff && pcenv && *pcenv) + ? (unsigned)atoi(pcenv) : 0; perfagent::ReplayLog replay; replay.on_replay_stall([&](const gpu_stall_reason_map_v1 &r) { if (gpu_stall_reason_map_v1_enabled()) @@ -506,6 +553,12 @@ perfagent_stub_run(unsigned launches, unsigned period_us, unsigned sample_period (unsigned long long)lb.dropped(), (unsigned long long)eb.dropped(), perfagent::enroll_result_name(enrolled), sem_at_enroll, gpu_launch_sampled_v1_semaphore_count(), enroll_name); + // Always, not only on the runs that sampled. "off" has to be readable in + // the output of a run that was off -- a line that appears only on the + // interesting runs is a line nobody checks on the boring ones, and the + // boring one is exactly where "off did not mean off" would hide. + fprintf(stderr, "stub: pc_sampling=%s pc_samples=%u sampling_windows=%u\n", + perfagent::pc_tier_name(tier), pc_samples, sampling_windows); if (sampling_windows) fprintf(stderr, "stub: sampling_windows=%u records=%lu last_open=%d " "exec_span=[%llu,%llu]\n",