Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions services/node/docs/PROFILING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# MPC Node Session Profiling (#244)

On-demand CPU/memory profiling per proof-generation session phase.

## Why not `pprof`?

The original issue asks for output "as pprof or flamegraph-compatible
format." A sampling profiler like the `pprof` crate walks *this node
process's own* call stack — but the actual MPC compute work for a session
runs in `co-noir` child processes (`session::run_proof_generation` spawns
one per phase: `merge_shares`, `witness_generation`, `proof_generation`).
An in-process profiler attached to the node would show almost nothing —
just the time spent spawning the child and waiting on it — because the
expensive work never executes on this process's stack at all.

Instead, profiling here samples each `co-noir` child process's OS-reported
CPU% and memory (RSS) on a fixed 200ms interval for the duration of its
phase, and aggregates that into a `PhaseProfile` per phase. This is
exported as JSON, not the pprof wire format: pprof's call-graph model
doesn't apply to an opaque external process whose internals this node has
no visibility into (a flamegraph needs sampled *stack traces*, which
`co-noir` doesn't expose to callers).

## API

Profiling is strictly **opt-in per session** — a session nobody asks to
profile pays zero sampling overhead beyond one registry lookup in
`post_generate`.

```
POST /session/:id/profile — enable profiling for this session.
Must be called before POST /session/:id/generate;
enabling it after generation has started (or
finished) has nothing left to sample.
GET /session/:id/profile — returns the SessionProfile collected so far,
as JSON. 404 if profiling was never enabled
for this session_id.
```

### Example response

```json
{
"session_id": "abc123",
"phases": [
{ "phase": "merge_shares", "duration_ms": 812, "peak_memory_bytes": 41943040, "sample_count": 4, "avg_cpu_percent": 12.5, "peak_cpu_percent": 30.0 },
{ "phase": "witness_generation", "duration_ms": 15420, "peak_memory_bytes": 536870912, "sample_count": 77, "avg_cpu_percent": 88.0, "peak_cpu_percent": 100.0 },
{ "phase": "proof_generation", "duration_ms": 42110, "peak_memory_bytes": 2147483648, "sample_count": 210, "avg_cpu_percent": 95.0, "peak_cpu_percent": 100.0 }
]
}
```

A retried `proof_generation` attempt (see the retry loop in
`run_proof_generation` for transient resource errors) appends another
`"proof_generation"` entry rather than overwriting the previous attempt's,
so all attempts remain visible.

## Precision note

`duration_ms` is measured at the sampler's 200ms sampling granularity, not
true child-process wall-clock time — a phase that finishes faster than one
sampling interval is reported as taking roughly one interval with zero
samples. In practice, MPC witness/proof generation phases run for seconds
to minutes, well above that granularity, so this is a deliberate
simplicity/precision tradeoff rather than a correctness gap for the phases
this actually profiles.

## Implementation

- `src/profiling.rs` — `ProfileRegistry` (which sessions are enabled + what's
been collected), `sample_process_until_exit` (the sampling loop, spawned
as its own task per phase so it runs concurrently with awaiting the
child).
- `src/session.rs`'s `run_profiled` helper wraps each `co-noir` subprocess
call: when profiling isn't enabled for the session it's exactly
`cmd.output().await` (zero extra cost); when it is, it spawns the child
with piped stdio, starts a sampler task against the child's pid, and
awaits both.
8 changes: 8 additions & 0 deletions services/node/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,13 @@ pub async fn post_generate(
let circuit_label = circuit_name.clone();
let finalized_sessions = state.finalized_sessions.clone();

// Profiling is strictly opt-in per session (issue #244): only pass the
// registry through when this session_id was explicitly enabled via
// POST /session/:id/profile before generation started. A session
// nobody asked to profile pays no sampling overhead.
let profiling_enabled = state.profiling.is_enabled(&sid).await;
let profile = profiling_enabled.then(|| state.profiling.clone());

tokio::spawn(async move {
let phase_timeouts = session::PhaseTimeouts::from_env();
let proof_future = session::run_proof_generation(
Expand All @@ -406,6 +413,7 @@ pub async fn post_generate(
crs_path,
limits,
phase_timeouts,
profile,
);

// Enforce a per-session wall-clock budget so a hung proof generation can't
Expand Down
6 changes: 6 additions & 0 deletions services/node/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ mod limits;
mod metrics;
mod pool;
mod private_table;
mod profiling;
mod session;
mod tls;
mod heartbeat;
Expand All @@ -48,6 +49,7 @@ mod gossip;
use limits::ResourceLimits;
use metrics::NodeMetrics;
use private_table::PrivateTableState;
use profiling::ProfileRegistry;
use session::MpcSessionState;

#[derive(Clone)]
Expand Down Expand Up @@ -256,6 +258,10 @@ async fn main() {
.route("/session/:id/generate", post(api::post_generate))
.route("/session/:id/status", get(api::get_status))
.route("/session/:id/proof", get(api::get_proof))
.route(
"/session/:id/profile",
post(api::post_enable_profiling).get(api::get_profile),
)
.with_state(state);

let addr = format!("0.0.0.0:{}", port);
Expand Down
Loading
Loading