Skip to content

gpu: attach to an already-running process, and keep its modules (#124) - #136

Merged
dpsoft merged 7 commits into
mainfrom
feat/124-attach-to-pid
Sep 8, 2026
Merged

gpu: attach to an already-running process, and keep its modules (#124)#136
dpsoft merged 7 commits into
mainfrom
feat/124-attach-to-pid

Conversation

@dpsoft

@dpsoft dpsoft commented Sep 8, 2026

Copy link
Copy Markdown
Owner

gpu-cuda-profile could only exec.Command its own workload. Every deployment that matters
attaches 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 tool
that has to be the parent of what it measures.

-pid attaches to a running process. -duration bounds the run, -drain-every bounds the
timeline's memory, -wait-for-shim bounds the precondition.

Verified on an RTX 3090 against the same workload profiled both ways.

The main risk was late CFI, and it is retired

A: launched B: attached 8s late
SampledLaunches 1,006 13,894
StacksWalkedDWARF 1,006 13,894
StacksWalkedNoTables 0 0
StackWalkReachedRoot 1,006 13,894
UnwindEnrollRequests 1 0
KernelNamesUnresolved 0 0

UnwindEnrollRequests: 0 confirms the startup rendezvous never happened — the target passed
cuInit long before this command existed. StacksWalkedNoTables: 0 across 13,894 sampled launches
says the eager registration path carried all of it: a non-zero Config.PID makes Attach
compile 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. -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. 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 environment
would 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:

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

Launch mode gets away with it because its workload stops; a collector never does, so the loss scales
with run length. Timeline.Snapshot already drains and was built to be called repeatedly — nothing
was calling it that way.

before after
samples in a 20s attach 65,536 111,056
executions evicted from the ring 45,032 0
launches whose executions could not join 45,668 88 (attach boundary)

The drain deliberately does not c.Flush(), though the end-of-run path must: mid-run, launches
held 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)

Snapshot drains the rings but not the launch cache, so its counters are cumulative and were
re-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 > 0
alone 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
LaunchCache to tell an evicted launch that already joined from one that never did, and reconciling
a 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_loaded refused to copy unless a consumer was already present. The vendor hands a
    module'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_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 100ms later.

Now: capture always, drain only when a consumer is attached, replay on the attach edge.
shim/core/drain.h's ReplayLog already did the third and nothing called its module half.

Order is load-bearing twice: the replay must precede 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.
And it 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.

The bounds are the policy, and they are now 512 modules / 64 MiB — the consumer's own
ModuleStore defaults, taken rather than invented. A process nobody ever profiles holds at most
that. 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:

modules_captured=1  module_retained_unattached=1  cubin_queue_full=0  cubins_sent=1
consumer: ModulesStored:1  ModulesWithLineInfo:1

byte-for-byte the launched run's result. The adapter's wiring needs CUPTI and is verified by that
run; what core holds is the property it rests on — an undrained queue retains every capture with
its bytes intact — plus the bounds, asserted where they live.

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.
@dpsoft dpsoft changed the title gpu: attach to an already-running process (#124) gpu: attach to an already-running process, and keep its modules (#124) Sep 8, 2026
@dpsoft
dpsoft merged commit a0e4fca into main Sep 8, 2026
11 checks passed
@dpsoft
dpsoft deleted the feat/124-attach-to-pid branch September 8, 2026 17:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant