gpu: attach to an already-running process, and keep its modules (#124) - #136
Merged
Conversation
Every deployment that matters attaches to a workload it did not launch. A sidecar profiles a container the kubelet started; a node collector profiles processes that were running before it was scheduled. gpu-cuda-profile could only exec.Command its own workload, which is why examples/kubernetes carries a manifest marked not-runnable: no cluster configuration fixes a tool that has to be the parent of what it measures. -pid attaches to a running process instead. -duration bounds the run; -wait-for-shim bounds the precondition. WHAT THE PID ACTUALLY BUYS, beyond narrowing the uprobe link: it takes gpuprobe's EAGER registration path, where Attach compiles that process's CFI tables synchronously, before the link exists and therefore before any probe can fire. This matters more here than in launch mode. The startup rendezvous cannot help a process that is already running -- it passed cuInit long before this command started -- so without the eager path a late attach would walk its first stacks with no tables, which is the ~38% loss issue #49 measured. The eager path does not narrow that window, it removes it: the tables are in before the first probe exists. REFUSED, NOT IGNORED. The producer's whole configuration -- sampling period, PC-sampling tier -- lives in the target's environment and is fixed at cuInit. -period accepted in attach mode would change nothing and would then be read off the command line afterwards as though it had, so the profile would be interpreted at a rate it was not taken at with nothing anywhere saying otherwise. A refusal costs one restart. Same for the tier, which is not merely inert but load-bearing: it gates whether an execution may be claimed gpu_serialized="true", and a consumer that read its own shell's environment would make that claim on nothing. Attach mode takes the zero value, which claims nothing and marks executions "unknown" -- correct-but-weaker, which is the right direction to be wrong in. The classification is checked for TOTALITY by a test over the registered flag set, not trusted. A flag added later and classified nowhere would fall through the refusal and be silently ignored in attach mode -- the exact failure the refusal exists to prevent, reintroduced by omission. Both directions are mutation-tested: an unclassified new flag fails, and a table entry naming a flag that no longer exists fails. THE PRECONDITION RUNS FIRST, before the store, the symbolizer or anything needing a capability. The driver dlopens the shim during cuInit and never again; nothing can add it to a live process. So a target that does not map it now never will, and this is knowable up front rather than after a full -duration of collecting nothing. A wrong -pid is the likeliest operator error and must not surface as a complaint about CAP_CHECKPOINT_RESTORE from setup a correct -pid would have needed anyway. "Looked and it is not there" and "could not look" are separate messages, because they send the reader to different places: how the target was started, versus their own command line. THE RUN ENDS WHEN THE TARGET DOES, and not as a courtesy. A sampled launch's stack is symbolized against /proc/<pid>/maps, which the kernel destroys the instant the process leaves; everything collected after that point has stacks that can no longer be resolved. Launch mode holds the workload's stdin open to prevent it. Attach mode has no handle on a process it did not create, so the best it can do is notice -- via a pidfd, which is bound to the process rather than to a number that can be reaped and reused while we watch. EINTR is not death: Poll is interruptible by any signal the Go runtime delivers, and reading it as exit would end every attached run at the first GC-related signal. Stated rather than discovered: attach mode is systematically weaker in one respect today. capture_enabled() in cupti_adapter.cc is g_consumer_enrolled || gpu_module_load_v1_enabled(), so the adapter captures a module's bytes only while a consumer is already present. A CUDA process loads essentially all its modules at startup, so attaching afterwards misses essentially all of them and every PC sample reads gpu_src_status="no-module". A run that hits this says so, and says it is not a transport failure, because the operator's next move should be the issue and not a socket that is working perfectly. shim/core/drain.h already carries a complete, tested ReplayLog for exactly this transition and nothing calls it.
Measured on an RTX 3090 while verifying attach mode. A 20 s attach to a
workload issuing ~5,500 launches/s reported:
gpu join: 65536 executions (65183 exact, 353 unmatched);
110851 launches (65183 matched, 45668 unmatched)
ANOMALY: launch cache evicted 45315 launches at capacity (65536 live)
ANOMALY: 45032 executions evicted from the timeline ring before this
snapshot -- that GPU time is missing from the profile entirely
The 65,536-entry execution ring fills at about twelve seconds at that rate.
Everything after that is evicted, and the launches waiting to join those
executions age out behind them.
This is not a bound that is too small. The tool held an entire run inside a
ring sized for an interval, because it only ever snapshotted once, at the end.
Launch mode gets away with it: it profiles a workload it started, which stops.
Attach mode is the first shape where the run has no natural end -- a sidecar or
a node collector runs for as long as it is scheduled -- so the same
simplification loses data in proportion to how long the run lasts. Raising the
capacity only moves the cliff and makes the agent's memory grow with the
length of the run, which for a collector is unbounded by construction.
Timeline.Snapshot already drains: it swaps in fresh rings and leaves whatever
it could not join eligible for a later call. It was built to be called
repeatedly and nothing was calling it that way. So the ring now holds one
-drain-every interval and the PROFILE accumulates across snapshots instead.
Two details that are easy to get backwards:
The drain does NOT c.Flush(), though the end-of-run path must. Flush releases
every launch being held for a sampled twin that has not arrived yet, and
mid-run most of those twins are a batch or two away rather than lost. Flushing
on a timer would release them stackless and convert launches that were about to
be attributed into [gpu:launch unsampled] -- a periodic drain that silently
degraded attribution while claiming to protect it. Held launches are not lost
by being skipped; they reach the sink when their twin arrives and land in the
next snapshot. Only the final drain, where nothing more is coming, forces it.
Per-snapshot health is printed only when there is something to say.
JoinHealthWith returns exactly one summary line when the join is clean, so
anything past the first is a warning or an anomaly -- which is precisely what
must not wait for the end of a long run to be seen. A clean line per interval
would put an unbounded stream of "no anomalies" into a collector's log and
train the reader to skip exactly the lines that matter.
Launch mode is unchanged: it calls the same collector once, at the same point
it used to snapshot.
Periodic drains introduced a reporting bug of their own. Snapshot drains the timeline's rings but NOT the launch cache, so LaunchCacheStats is cumulative for the life of the run: its eviction total is re-reported, larger, in every subsequent snapshot. A 20 s attach printed snapshot 7: launch cache evicted 10198 launches at capacity (65536 live) snapshot 8: launch cache evicted 20824 launches at capacity (65536 live) snapshot 9: launch cache evicted 31547 launches at capacity (65536 live) snapshot 10: launch cache evicted 45034 launches at capacity (65536 live) which is one ongoing condition wearing the costume of four fresh anomalies. Over a collector's lifetime it is unbounded. An operator who sees the same alarm ten times learns to skip it, which is precisely the harm the anomaly exists to prevent -- and the next one, the real one, is skipped with it. Anomalies are now reported once per KIND, the kind being the line with its numbers removed. A condition that persists is announced when it starts and then stays quiet; the final health block still prints in full and carries the totals. The test uses the four actual lines from the 3090 run and also asserts the collapse does not over-reach: two different findings must stay two. Not fixed here, and worth stating rather than leaving to be discovered: that eviction anomaly is over-firing on its own terms. It fires on EvictedCapacity > 0 alone and asserts a consequence -- "so their executions cannot join" -- that this run did not have. 110,144 samples were projected from ~110,851 launches and the final snapshot joined every execution exactly; the cache was doing what a bounded LRU is for. Making it precise means teaching LaunchCache to tell an evicted launch that already joined from one that never did, and it means reconciling a cumulative counter against a per-snapshot one. That is a change to shared join-health semantics with its own tests, not a tail-end edit to this one.
A consumer attaching to a running process found ZERO modules. Not most of them, all of them -- measured on an RTX 3090: CubinsReceived 0, ModulesStored 0, every PC sample gpu_src_status="no-module", against a launched run of the same workload that resolved its module with line info. Two gates caused it, and only fixing both helps. on_module_loaded refused to copy unless a consumer was already present. The vendor hands over a module's bytes exactly once, inside that callback, in a buffer it reuses afterwards -- and CUPTI offers no way to re-enumerate what is already loaded. So a module not copied there is gone for the life of the process. A CUDA process loads essentially every module it will use during startup, which is why the loss is total rather than partial: by the time any collector can attach, the whole set has already been declined. on_tick then drained the queue unconditionally. drain() pops and frees whatever it takes regardless of whether the offer landed, so draining into a socket nobody is listening on is indistinguishable from deleting the modules. Making capture unconditional without this would have copied every module and thrown it away 100 ms later. Now: capture always, drain only when a consumer is attached, and replay on the unattached -> attached edge. shim/core/drain.h's ReplayLog already did the third and nothing called its module half. ORDER IS LOAD-BEARING, twice. The replay runs BEFORE the drain because gpu_module_load_v1 carries bytes_ptr into the adapter's own copy and the drain frees that copy the moment it has offered it -- replaying afterwards would announce modules by a pointer that had just become dangling. And a consumer receiving bytes for a module it had never been told about would have to hold them speculatively. The replay also had to move above the Tier B gate: left where it was, `if (!g_pc_enabled) return` sits between it and the top, so it would never run at all in the DEFAULT configuration -- retaining every module and announcing none. LIVE OR LOGGED, NEVER BOTH. on_cubin_captured emits when the probe is armed and logs for replay when it is not, so the log holds exactly the records nobody was there to hear. A launched run does not receive its modules twice. THE BOUNDS ARE THE POLICY. They were sized for a queue drained every 100 ms, where 32 entries could not accumulate; they now hold a process's modules for as long as nobody has arrived, and 32 is not a process's worth. They are 512 and 64 MiB -- the CONSUMER's ModuleStore defaults, taken rather than invented, because there is no value in retaining more than the store on the other end can hold and no reason to retain fewer. Past them capture() drops the offer and counts it, so a process nobody ever profiles holds at most that. The shim's PRESENCE is the operator's opt-in: a process without CUDA_INJECTION64_PATH never maps it, so this memory is only ever held by a process somebody asked to be able to profile. g_module_unattached counted modules DECLINED. Nothing is declined now, so it is g_module_retained and counts what is being held -- a gauge of what this process carries on a profiler's behalf, and the number that says whether a late attach found anything to work with. Verified end to end on the 3090, attaching six seconds into a running workload: modules_captured=1 module_retained_unattached=1 cubin_queue_full=0 cubins_sent=1 consumer: ModulesStored:1 ModulesWithLineInfo:1 which is byte-for-byte the launched run's result (Live:1 LiveBytes:7848). Tested in core rather than in the adapter, and the split is not hidden: the adapter's wiring needs CUPTI and is verified by the run above. What core can hold is the property that wiring rests on -- that an undrained queue retains every capture with its bytes intact and delivers them all on the first drain afterwards, which guards against the plausible "optimisation" of making the queue self-trim now that it is no longer transient -- plus the bounds themselves, asserted where they live so that if either side moves, the two are reconciled here.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
gpu-cuda-profilecould onlyexec.Commandits own workload. Every deployment that mattersattaches to a process it did not start — a sidecar profiles a container the kubelet started, a node
collector profiles processes that were running before it was scheduled — which is why
examples/kubernetes/carries a manifest marked not-runnable. No cluster configuration fixes a toolthat has to be the parent of what it measures.
-pidattaches to a running process.-durationbounds the run,-drain-everybounds thetimeline's memory,
-wait-for-shimbounds the precondition.Verified on an RTX 3090 against the same workload profiled both ways.
The main risk was late CFI, and it is retired
UnwindEnrollRequests: 0confirms the startup rendezvous never happened — the target passedcuInitlong before this command existed.StacksWalkedNoTables: 0across 13,894 sampled launchessays the eager registration path carried all of it: a non-zero
Config.PIDmakesAttachcompile that process's CFI tables synchronously, before the uprobe link exists and therefore before
any probe can fire. The window is not narrowed but absent. Without it a late attach would walk its
first stacks with no tables — the ~38% loss #49 measured.
Refused, not ignored
The producer's whole configuration — sampling period, PC-sampling tier — lives in the target's
environment and is fixed at
cuInit.-periodaccepted in attach mode would change nothing andwould then be read off the command line afterwards as though it had, so the profile would be
interpreted at a rate it was not taken at. The tier is worse than inert: it gates whether an
execution may be claimed
gpu_serialized="true", and a consumer reading its own shell's environmentwould make that claim on nothing. Attach mode takes the zero value, which claims nothing and marks
executions
"unknown"— correct-but-weaker, the right direction to be wrong in.The classification is checked for totality by a test over the registered flag set. A flag added
later and classified nowhere would fall through the refusal and be silently ignored — the exact
failure the refusal prevents, reintroduced by omission. Mutation-tested both ways.
The long window exposed a real defect (commit 2)
The tool snapshotted once, at the end, holding a whole run inside a ring sized for an interval.
At ~5,500 launches/s the 65,536-entry ring fills in ~12s:
Launch mode gets away with it because its workload stops; a collector never does, so the loss scales
with run length.
Timeline.Snapshotalready drains and was built to be called repeatedly — nothingwas calling it that way.
The drain deliberately does not
c.Flush(), though the end-of-run path must: mid-run, launchesheld for a sampled twin are usually a batch away rather than lost, and flushing on a timer would
release them stackless — a periodic drain that silently degraded attribution while claiming to
protect it.
And a reporting bug the drain introduced (commit 3)
Snapshotdrains the rings but not the launch cache, so its counters are cumulative and werere-reported, larger, every interval — one ongoing condition wearing the costume of four fresh
anomalies, unbounded over a collector's lifetime. Anomalies now report once per kind.
Stated, not hidden
The launch-cache anomaly is over-firing on its own terms. It fires on
EvictedCapacity > 0alone and asserts "their executions cannot join", which this run disproves — 111,056 samples from
~110,851 launches, final snapshot joining every execution exactly. Making it precise means teaching
LaunchCacheto tell an evicted launch that already joined from one that never did, and reconcilinga cumulative counter against a per-snapshot one: shared join-health semantics with its own tests,
filed as #137 rather than edited in here.
Modules survive a late attach too (commit 5)
The measurement said the module loss was 100%, not partial — so this went in rather than being
deferred. Two gates caused it and only fixing both helps:
on_module_loadedrefused to copy unless a consumer was already present. The vendor hands amodule's bytes over exactly once, inside that callback, in a buffer it reuses afterwards, and
CUPTI offers no way to re-enumerate what is loaded. A module not copied there is gone for the life
of the process — and a CUDA process loads essentially all of them at startup, which is why the
loss was total.
on_tickthen drained the queue unconditionally.drain()pops and frees whatever it takesregardless of whether the offer landed, so draining into a socket nobody is listening on is
indistinguishable from deleting the modules. Making capture unconditional without this would have
copied every module and thrown it away 100ms later.
Now: capture always, drain only when a consumer is attached, replay on the attach edge.
shim/core/drain.h'sReplayLogalready did the third and nothing called its module half.Order is load-bearing twice: the replay must precede the drain, because
gpu_module_load_v1carriesbytes_ptrinto the adapter's own copy and the drain frees that copy the moment it has offered it.And it had to move above the Tier B gate — left where it was,
if (!g_pc_enabled) returnsitsbetween it and the top, so it would never run at all in the default configuration.
The bounds are the policy, and they are now 512 modules / 64 MiB — the consumer's own
ModuleStoredefaults, taken rather than invented. A process nobody ever profiles holds at mostthat. The shim's presence is the operator's opt-in, so this memory is only ever held by a process
somebody asked to be able to profile.
Verified on the 3090, attaching six seconds into a running workload:
byte-for-byte the launched run's result. The adapter's wiring needs CUPTI and is verified by that
run; what
coreholds is the property it rests on — an undrained queue retains every capture withits bytes intact — plus the bounds, asserted where they live.