Skip to content

Commit c8a8ec3

Browse files
authored
Merge pull request #11 from buffrr/prover-limits-ui-fixes
Prover limits UI fixes
2 parents 59a381c + 8bd8202 commit c8a8ec3

5 files changed

Lines changed: 152 additions & 48 deletions

File tree

core/src/app.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1303,7 +1303,13 @@ impl Operator {
13031303
};
13041304

13051305
// has_pending is true until fully done (published)
1306-
let has_pending = !is_done;
1306+
// Publishing does not block the next commitment. broadcast/confirmed/
1307+
// finalized are on-chain and strictly sequential — two commitments
1308+
// cannot race — but publishing is downstream distribution of an
1309+
// already-final commitment. The background loop publishes on its own
1310+
// timer, and publish_certs selects across commitments, so a later
1311+
// commitment's certificates are simply picked up by the same sweep.
1312+
let has_pending = !is_done && current_step.as_deref() != Some("published");
13071313

13081314
// Reset stale temp certs when the on-chain tip has changed,
13091315
// so handles published against an old tip get republished

prover/src/main.rs

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,15 @@ struct Cli {
4040
#[arg(long, default_value = "8888")]
4141
server_port: u16,
4242

43+
/// Skip startup calibration (for --server mode).
44+
///
45+
/// Calibration proves a small batch to measure throughput, and the server
46+
/// does not accept connections until it finishes — including /health. On a
47+
/// short-lived GPU pod that delay is billed on every cold start, so skip it
48+
/// when estimates aren't needed. Also settable via PROVER_NO_CALIBRATE.
49+
#[arg(long)]
50+
no_calibrate: bool,
51+
4352
#[command(subcommand)]
4453
cmd: Option<Commands>,
4554
}
@@ -80,7 +89,9 @@ async fn main() -> Result<()> {
8089
let cli = Cli::parse();
8190

8291
if cli.server {
83-
subs_prover::server::run_server(cli.server_port).await?;
92+
let no_calibrate = cli.no_calibrate
93+
|| std::env::var("PROVER_NO_CALIBRATE").is_ok_and(|v| !v.is_empty() && v != "0");
94+
subs_prover::server::run_server(cli.server_port, no_calibrate).await?;
8495
return Ok(());
8596
}
8697

@@ -160,7 +171,10 @@ fn compress(input: &CompressInput) -> Result<Vec<u8>> {
160171
}
161172

162173
fn run_bench(existing: usize, insert: usize) -> Result<()> {
163-
eprintln!("Building tree with {} existing handles, {} inserts...", existing, insert);
174+
eprintln!(
175+
"Building tree with {} existing handles, {} inserts...",
176+
existing, insert
177+
);
164178
let start = std::time::Instant::now();
165179
let request = subs_prover::build_bench_request(existing, insert)?;
166180
eprintln!("Request built in {:.2}s", start.elapsed().as_secs_f64());
@@ -181,15 +195,22 @@ fn run_bench(existing: usize, insert: usize) -> Result<()> {
181195
}
182196
};
183197

184-
eprintln!("Estimating proof for {} handles inserted into tree of {}...", insert, existing);
198+
eprintln!(
199+
"Estimating proof for {} handles inserted into tree of {}...",
200+
insert, existing
201+
);
185202
let estimate = prover.estimate(&request, calibration.as_ref())?;
186203

187204
eprintln!("\n=== Estimate ===");
188205
eprintln!("Total user cycles: {}", estimate.total_cycles);
189-
eprintln!("Total proving cycles: {} (padded)", estimate.total_proving_cycles);
206+
eprintln!(
207+
"Total proving cycles: {} (padded)",
208+
estimate.total_proving_cycles
209+
);
190210
eprintln!("Segments: {}", estimate.segments);
191211
for (i, seg) in estimate.segment_details.iter().enumerate() {
192-
let time_str = seg.estimated_seconds
212+
let time_str = seg
213+
.estimated_seconds
193214
.map(|s| format!("{:.2}s", s))
194215
.unwrap_or_else(|| "n/a".into());
195216
eprintln!(

prover/src/server.rs

Lines changed: 73 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use std::net::SocketAddr;
88
use std::sync::Arc;
99

1010
use axum::{
11-
extract::{Path, Request, State},
11+
extract::{DefaultBodyLimit, Path, Request, State},
1212
http::StatusCode,
1313
middleware::{self, Next},
1414
response::{IntoResponse, Response},
@@ -101,7 +101,20 @@ pub struct ErrorResponse {
101101
}
102102

103103
/// Start the prover server
104-
pub async fn run_server(port: u16) -> anyhow::Result<()> {
104+
/// Maximum accepted request body.
105+
///
106+
/// Proving requests scale with the batch: the zk input alone is 64 bytes per
107+
/// handle, so a 50k-handle commitment is ~3 MB before the exclusion proof, and
108+
/// axum's 2 MB default rejects it with a 413 that reads like a prover fault.
109+
/// Bodies are buffered in memory, so this is a memory bound as much as a
110+
/// policy one — generous rather than tight, since the failure mode of setting
111+
/// it too low is a rejected commitment, and PROVER_AUTH_TOKEN gates the port.
112+
///
113+
/// Kept in step with MAX_BODY_BYTES in the runpod proxy: whichever hop has the
114+
/// lower ceiling is the one that 413s.
115+
const MAX_BODY_BYTES: usize = 512 * 1024 * 1024;
116+
117+
pub async fn run_server(port: u16, no_calibrate: bool) -> anyhow::Result<()> {
105118
// Initialize tracing
106119
tracing_subscriber::fmt()
107120
.with_env_filter(
@@ -116,28 +129,34 @@ pub async fn run_server(port: u16) -> anyhow::Result<()> {
116129
// Create shared state
117130
let state = Arc::new(ServerState::new(tx));
118131

119-
// Calibrate proving throughput on startup
120-
tracing::info!("Calibrating proving throughput...");
121-
let calibrate_state = state.clone();
122-
let calibrate_handle = tokio::task::spawn_blocking(move || {
123-
let prover = Prover::new();
124-
prover.calibrate()
125-
});
126-
match calibrate_handle.await {
127-
Ok(Ok(info)) => {
128-
tracing::info!(
129-
"Calibration complete: {:.2}s per segment at po2={}, {:.0} cycles/sec",
130-
info.seconds_per_segment,
131-
info.calibration_po2,
132-
info.cycles_per_sec,
133-
);
134-
*calibrate_state.calibration.write().await = Some(info);
135-
}
136-
Ok(Err(e)) => {
137-
tracing::warn!("Calibration failed (estimates will be unavailable): {}", e);
138-
}
139-
Err(e) => {
140-
tracing::warn!("Calibration task panicked: {}", e);
132+
// Calibrate proving throughput on startup. This blocks the listener, so
133+
// /health stays unanswered until it completes — deliberate, since an
134+
// estimate is useless before it, but billable on a short-lived pod.
135+
if no_calibrate {
136+
tracing::info!("Calibration skipped (--no-calibrate); /estimate will be unavailable");
137+
} else {
138+
tracing::info!("Calibrating proving throughput...");
139+
let calibrate_state = state.clone();
140+
let calibrate_handle = tokio::task::spawn_blocking(move || {
141+
let prover = Prover::new();
142+
prover.calibrate()
143+
});
144+
match calibrate_handle.await {
145+
Ok(Ok(info)) => {
146+
tracing::info!(
147+
"Calibration complete: {:.2}s per segment at po2={}, {:.0} cycles/sec",
148+
info.seconds_per_segment,
149+
info.calibration_po2,
150+
info.cycles_per_sec,
151+
);
152+
*calibrate_state.calibration.write().await = Some(info);
153+
}
154+
Ok(Err(e)) => {
155+
tracing::warn!("Calibration failed (estimates will be unavailable): {}", e);
156+
}
157+
Err(e) => {
158+
tracing::warn!("Calibration task panicked: {}", e);
159+
}
141160
}
142161
}
143162

@@ -150,7 +169,9 @@ pub async fn run_server(port: u16) -> anyhow::Result<()> {
150169
// Optional bearer-token auth. If PROVER_AUTH_TOKEN is set, every route
151170
// (including /health) requires `Authorization: Bearer <token>` — that
152171
// way a successful /health probe also confirms auth is wired correctly.
153-
let auth_token = std::env::var("PROVER_AUTH_TOKEN").ok().filter(|s| !s.is_empty());
172+
let auth_token = std::env::var("PROVER_AUTH_TOKEN")
173+
.ok()
174+
.filter(|s| !s.is_empty());
154175
if auth_token.is_some() {
155176
tracing::info!("PROVER_AUTH_TOKEN set, requiring bearer auth on all routes");
156177
}
@@ -161,7 +182,8 @@ pub async fn run_server(port: u16) -> anyhow::Result<()> {
161182
.route("/estimate", post(submit_estimate))
162183
.route("/compress", post(submit_compress))
163184
.route("/jobs/:job_id", get(get_job_status))
164-
.route("/jobs/:job_id/receipt", get(get_job_receipt));
185+
.route("/jobs/:job_id/receipt", get(get_job_receipt))
186+
.route("/calibration", get(get_calibration));
165187
if let Some(token) = auth_token {
166188
app = app.layer(middleware::from_fn(move |req: Request, next: Next| {
167189
let token = token.clone();
@@ -170,6 +192,7 @@ pub async fn run_server(port: u16) -> anyhow::Result<()> {
170192
}
171193

172194
let app = app
195+
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
173196
.layer(TraceLayer::new_for_http())
174197
.layer(
175198
CorsLayer::new()
@@ -192,6 +215,29 @@ pub async fn run_server(port: u16) -> anyhow::Result<()> {
192215
}
193216

194217
/// Health check endpoint
218+
/// GET /calibration - Measured proving throughput of this machine.
219+
///
220+
/// The number that characterises a GPU for cost purposes: cost per proof is
221+
/// total_proving_cycles / cycles_per_sec. Previously only reachable by reading
222+
/// the startup log line or running `subs-prover bench` on the box.
223+
///
224+
/// 503 when calibration was skipped or failed.
225+
async fn get_calibration(State(state): State<Arc<ServerState>>) -> impl IntoResponse {
226+
match state.calibration.read().await.clone() {
227+
Some(info) => Json(serde_json::json!({
228+
"seconds_per_segment": info.seconds_per_segment,
229+
"calibration_po2": info.calibration_po2,
230+
"cycles_per_sec": info.cycles_per_sec,
231+
}))
232+
.into_response(),
233+
None => (
234+
StatusCode::SERVICE_UNAVAILABLE,
235+
"calibration unavailable (skipped or failed)",
236+
)
237+
.into_response(),
238+
}
239+
}
240+
195241
async fn health() -> &'static str {
196242
"ok"
197243
}
@@ -435,10 +481,7 @@ async fn get_job_receipt(
435481
tracing::info!("Job {} receipt pulled, removing job", job_id);
436482
(
437483
StatusCode::OK,
438-
[(
439-
axum::http::header::CONTENT_TYPE,
440-
"application/octet-stream",
441-
)],
484+
[(axum::http::header::CONTENT_TYPE, "application/octet-stream")],
442485
receipt,
443486
)
444487
.into_response()

subs/src/routes/commits.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,11 @@ pub struct PipelineResponse {
338338
pub prover_configured: bool,
339339
/// Whether a proving job is currently in flight on the prover
340340
pub proving_job_active: bool,
341+
/// Which proof of the commitment is next (1-based), when proving.
342+
pub proof_index: Option<usize>,
343+
/// How many proofs this commitment needs in total: the first commitment
344+
/// after genesis needs only a step, later ones need step + fold.
345+
pub proof_total: Option<usize>,
341346
}
342347

343348
pub async fn get_pipeline_status(
@@ -366,11 +371,21 @@ pub async fn get_pipeline_status(
366371
// Check if there's an active proving job by looking for a job key in config.
367372
// The job key uses the commitment's SQLite row id from the proving request,
368373
// matching the format used by push_to_prover and the background loop.
369-
let proving_job_active = if status.commitment_idx.is_some() {
374+
// Also report which proof of this commitment is next. count_pending_proofs
375+
// charges a fold only from idx >= 2, so the first commitment after genesis
376+
// is a single step proof and later ones are step + fold.
377+
let mut proof_index = None;
378+
let mut proof_total = None;
379+
380+
let proving_job_active = if let Some(idx) = status.commitment_idx {
370381
if let Ok(Some(req)) = state.operator.get_next_proving_request(&space_label).await {
371382
let cid = req.commitment_id();
372383
let is_fold = matches!(&req, subs_types::ProvingRequest::Fold { .. });
373384
let kind = if is_fold { "fold" } else { "step" };
385+
386+
proof_total = Some(if idx >= 2 { 2 } else { 1 });
387+
proof_index = Some(if is_fold { 2 } else { 1 });
388+
374389
let job_key = format!("job:{}:{}:{}", space, cid, kind);
375390
state.config.get(&job_key).unwrap_or(None).is_some()
376391
} else {
@@ -384,5 +399,7 @@ pub async fn get_pipeline_status(
384399
status,
385400
prover_configured,
386401
proving_job_active,
402+
proof_index,
403+
proof_total,
387404
}))
388405
}

subs/templates/space.html

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,10 @@ <h3>Handles</h3>
225225
let currentPage = 1;
226226
let perPage = 20;
227227
let currentPipeline = null;
228-
let provingPollActive = false;
228+
// Handle for the pipeline auto-refresh timer. Kept as a handle rather than a
229+
// boolean so it can be cleared and re-armed on every pass: a flag that is only
230+
// ever set true stops the chain after a single tick.
231+
let pipelinePollTimer = null;
229232
let currentFilter = null;
230233
let searchTimeout = null;
231234
let selectedHandles = new Set();
@@ -297,6 +300,23 @@ <h3>Handles</h3>
297300

298301
updatePublishBar(j.unpublished || 0);
299302

303+
// Re-arm before the has_pending branch below: publishing no longer
304+
// sets has_pending, so that branch returns early and would otherwise
305+
// never reach this. Poll while the pipeline advances on its own — the
306+
// prover working, the chain confirming, the background loop
307+
// publishing. 'broadcast' is excluded; it waits on the operator.
308+
if (pipelinePollTimer) {
309+
clearTimeout(pipelinePollTimer);
310+
pipelinePollTimer = null;
311+
}
312+
if ((j.steps && j.steps.proving === 'in_progress' && j.prover_configured)
313+
|| j.current_step === 'confirmed'
314+
|| j.current_step === 'finalized'
315+
|| j.current_step === 'published'
316+
|| (j.unpublished || 0) > 0) {
317+
pipelinePollTimer = setTimeout(refreshPipeline, 5000);
318+
}
319+
300320
if (!j.has_pending) {
301321
const sc = j.staged_count || 0;
302322
if (sc > 0) {
@@ -323,14 +343,6 @@ <h3>Handles</h3>
323343
renderActions(j);
324344
$('provingEndpoint').innerHTML = '';
325345

326-
if (j.steps.proving === 'in_progress' && j.prover_configured) {
327-
if (!provingPollActive) {
328-
provingPollActive = true;
329-
setTimeout(refreshPipeline, 5000);
330-
}
331-
} else {
332-
provingPollActive = false;
333-
}
334346
loadHandles(currentPage);
335347
} catch (e) {
336348
$('pipelineStepper').innerHTML = `<span style="color:var(--red);font-size:13px">${e.message}</span>`;
@@ -401,14 +413,19 @@ <h3>Handles</h3>
401413
let h = '';
402414

403415
if (r.current_step === 'proving') {
416+
// "1 of 2" / "2 of 2" so an interrupted run and a normal handoff
417+
// between the step and fold proofs don't look identical.
418+
const proofLabel = (r.proof_index && r.proof_total && r.proof_total > 1)
419+
? ` ${r.proof_index} of ${r.proof_total}`
420+
: '';
404421
if (r.prover_configured) {
405422
if (r.proving_job_active) {
406423
const total = r.pending_proofs || 0;
407424
h += `<div class="flex items-center gap-2" style="color:var(--accent)">
408425
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" class="animate-spin">
409426
<circle opacity="0.25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
410427
<path opacity="0.75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
411-
<span style="font-size:13px">Generating proof...
428+
<span style="font-size:13px">Generating proof${proofLabel}...
412429
<span style="color:var(--text-muted)">(${total} pending)</span></span></div>`;
413430
}
414431
if (r.estimate) {
@@ -424,7 +441,7 @@ <h3>Handles</h3>
424441
}
425442
if (!r.proving_job_active) {
426443
h += `<div class="flex gap-3" style="margin-top:10px">
427-
<button onclick="startProving()" class="btn-primary">Prove</button>
444+
<button onclick="startProving()" class="btn-primary">Prove${proofLabel ? ' ' + proofLabel.trim() : ''}</button>
428445
<button onclick="rollbackLocal()" class="btn-secondary">Rollback</button></div>`;
429446
} else {
430447
h += '<button onclick="rollbackLocal()" class="btn-sm" style="margin-top:10px">Rollback Local</button>';

0 commit comments

Comments
 (0)