1use std::env;
2use std::error::Error;
3use std::fs::{self, File};
4use std::io::{BufRead, BufReader};
5use std::path::{Path, PathBuf};
6use std::time::Instant;
7
8use hybit::{
9 analyze_csr32, pcg_with_workspace, read_matrix_market, recommend_rigid_body_aggregate_nodes,
10 Csr32Matrix, HybitError, KrylovOutcome, LinearOperator, ParallelCsr32Operator,
11 ParallelRigidBodyTwoLevelPreconditioner, PcgWorkspace, Preconditioner, RigidBodyAggregation,
12 RigidBodyTwoLevelBlockJacobiPreconditioner, SolveStatus, SolverOptions,
13};
14
15#[derive(Debug)]
16struct Args {
17 matrix: PathBuf,
18 coordinates: PathBuf,
19 rhs: Option<PathBuf>,
20 relative_tolerance: f64,
21 max_iterations: usize,
22 target_coarse_dimension: usize,
23 aggregation: RigidBodyAggregation,
24}
25
26impl Args {
27 fn parse() -> Result<Self, Box<dyn Error>> {
28 let mut matrix = None;
29 let mut coordinates = None;
30 let mut rhs = None;
31 let mut relative_tolerance: f64 = 1.0e-8;
32 let mut max_iterations = 3000usize;
33 let mut target_coarse_dimension = 1536usize;
34 let mut aggregation = RigidBodyAggregation::Graph;
35 let mut it = env::args().skip(1);
36 while let Some(arg) = it.next() {
37 match arg.as_str() {
38 "--matrix" => matrix = Some(PathBuf::from(next_value(&mut it, "--matrix")?)),
39 "--coords" => coordinates = Some(PathBuf::from(next_value(&mut it, "--coords")?)),
40 "--rhs" => rhs = Some(PathBuf::from(next_value(&mut it, "--rhs")?)),
41 "--tol" => relative_tolerance = next_value(&mut it, "--tol")?.parse()?,
42 "--max-iters" => max_iterations = next_value(&mut it, "--max-iters")?.parse()?,
43 "--target-coarse-dim" => {
44 target_coarse_dimension = next_value(&mut it, "--target-coarse-dim")?.parse()?
45 }
46 "--aggregation" => {
47 aggregation = match next_value(&mut it, "--aggregation")?
48 .to_ascii_lowercase()
49 .as_str()
50 {
51 "contiguous" => RigidBodyAggregation::Contiguous,
52 "graph" => RigidBodyAggregation::Graph,
53 other => {
54 return Err(format!(
55 "unknown aggregation '{other}'; use contiguous or graph"
56 )
57 .into())
58 }
59 }
60 }
61 "-h" | "--help" => {
62 print_usage();
63 std::process::exit(0);
64 }
65 other if !other.starts_with('-') && matrix.is_none() => {
66 matrix = Some(PathBuf::from(other))
67 }
68 other => return Err(format!("unknown argument '{other}'").into()),
69 }
70 }
71 let matrix = matrix.ok_or("missing matrix path; use --matrix FILE.mtx")?;
72 let coordinates = coordinates.unwrap_or_else(|| matrix.with_extension("coords"));
73 if !relative_tolerance.is_finite() || relative_tolerance <= 0.0 {
74 return Err("--tol must be finite and > 0".into());
75 }
76 if max_iterations == 0 {
77 return Err("--max-iters must be > 0".into());
78 }
79 if target_coarse_dimension < 6 {
80 return Err("--target-coarse-dim must be >= 6".into());
81 }
82 Ok(Self {
83 matrix,
84 coordinates,
85 rhs,
86 relative_tolerance,
87 max_iterations,
88 target_coarse_dimension,
89 aggregation,
90 })
91 }
92}
93
94fn next_value<I: Iterator<Item = String>>(
95 it: &mut I,
96 flag: &str,
97) -> Result<String, Box<dyn Error>> {
98 it.next()
99 .ok_or_else(|| format!("missing value after {flag}").into())
100}
101
102fn print_usage() {
103 println!("HyBIT PCG vector-kernel profile");
104 println!("Usage: fem_structural_pcg_profile --matrix K.mtx [--coords K.coords] [--rhs b.txt] [--tol 1e-8] [--max-iters 3000] [--target-coarse-dim 1536] [--aggregation graph|contiguous]");
105 println!("The profiled path uses Parallel CSR + Parallel rigid-body preconditioner and times actual PCG stages.");
106 println!("Set RAYON_NUM_THREADS before launch to control the shared Rayon pool.");
107}
108
109fn read_coordinates(path: &Path) -> Result<Vec<[f64; 3]>, Box<dyn Error>> {
110 let file = File::open(path)?;
111 let reader = BufReader::new(file);
112 let mut expected = None::<usize>;
113 let mut coordinates = Vec::new();
114 for (line_no, line) in reader.lines().enumerate() {
115 let line = line?;
116 let text = line.trim();
117 if text.is_empty() || text.starts_with('#') {
118 continue;
119 }
120 if expected.is_none() {
121 expected = Some(text.parse::<usize>().map_err(|e| {
122 format!(
123 "{}:{}: invalid coordinate count: {e}",
124 path.display(),
125 line_no + 1
126 )
127 })?);
128 coordinates.reserve(expected.unwrap());
129 continue;
130 }
131 let fields: Vec<&str> = text.split_whitespace().collect();
132 if fields.len() != 3 {
133 return Err(format!(
134 "{}:{}: expected three coordinates",
135 path.display(),
136 line_no + 1
137 )
138 .into());
139 }
140 let xyz = [
141 fields[0].parse::<f64>()?,
142 fields[1].parse::<f64>()?,
143 fields[2].parse::<f64>()?,
144 ];
145 if xyz.iter().any(|v| !v.is_finite()) {
146 return Err(
147 format!("{}:{}: non-finite coordinate", path.display(), line_no + 1).into(),
148 );
149 }
150 coordinates.push(xyz);
151 }
152 let expected =
153 expected.ok_or_else(|| format!("{}: missing coordinate count", path.display()))?;
154 if coordinates.len() != expected {
155 return Err(format!(
156 "{}: coordinate count mismatch: header says {}, read {}",
157 path.display(),
158 expected,
159 coordinates.len()
160 )
161 .into());
162 }
163 Ok(coordinates)
164}
165
166fn load_rhs(path: &Path, n: usize) -> Result<Vec<f64>, Box<dyn Error>> {
167 let text = fs::read_to_string(path)?;
168 let mut values = Vec::with_capacity(n);
169 for (line_index, raw_line) in text.lines().enumerate() {
170 let line = raw_line.trim_start_matches('\u{feff}').trim();
171 if line.is_empty() || line.starts_with('#') || line.starts_with('%') {
172 continue;
173 }
174 for (token_index, token) in line.split_whitespace().enumerate() {
175 let value = token.parse::<f64>().map_err(|e| {
176 format!(
177 "{}: invalid RHS float at line {}, token {}: {:?} ({e})",
178 path.display(),
179 line_index + 1,
180 token_index + 1,
181 token
182 )
183 })?;
184 if !value.is_finite() {
185 return Err(format!(
186 "{}: non-finite RHS value at line {}, token {}",
187 path.display(),
188 line_index + 1,
189 token_index + 1
190 )
191 .into());
192 }
193 values.push(value);
194 }
195 }
196 if values.len() != n {
197 return Err(format!(
198 "{}: RHS length mismatch: expected {n}, got {}",
199 path.display(),
200 values.len()
201 )
202 .into());
203 }
204 Ok(values)
205}
206
207fn dot_serial(a: &[f64], b: &[f64]) -> f64 {
208 debug_assert_eq!(a.len(), b.len());
209 a.iter().zip(b).map(|(x, y)| x * y).sum()
210}
211fn norm2(x: &[f64]) -> f64 {
212 dot_serial(x, x).sqrt()
213}
214fn mib(bytes: usize) -> f64 {
215 bytes as f64 / (1024.0 * 1024.0)
216}
217
218fn verified_relative_residual(
219 a: &Csr32Matrix,
220 b: &[f64],
221 x: &[f64],
222) -> Result<f64, Box<dyn Error>> {
223 let ax = a.spmv(x)?;
224 let rr = b
225 .iter()
226 .zip(&ax)
227 .map(|(&bi, &ai)| {
228 let r = bi - ai;
229 r * r
230 })
231 .sum::<f64>()
232 .sqrt();
233 let bn = norm2(b);
234 Ok(if bn == 0.0 { rr } else { rr / bn })
235}
236
237#[derive(Clone, Copy, Debug, Default)]
238struct PcgStageProfile {
239 operator_seconds: f64,
240 preconditioner_seconds: f64,
241 dot_seconds: f64,
242 norm_seconds: f64,
243 residual_init_seconds: f64,
244 update_x_r_seconds: f64,
245 update_p_seconds: f64,
246 copy_p_seconds: f64,
247 operator_calls: usize,
248 preconditioner_calls: usize,
249 dot_calls: usize,
250 norm_calls: usize,
251 update_x_r_calls: usize,
252 update_p_calls: usize,
253}
254
255impl PcgStageProfile {
256 fn accounted_seconds(&self) -> f64 {
257 self.operator_seconds
258 + self.preconditioner_seconds
259 + self.dot_seconds
260 + self.norm_seconds
261 + self.residual_init_seconds
262 + self.update_x_r_seconds
263 + self.update_p_seconds
264 + self.copy_p_seconds
265 }
266}
267
268fn timed_dot(a: &[f64], b: &[f64], profile: &mut PcgStageProfile) -> f64 {
269 let start = Instant::now();
270 let value = dot_serial(a, b);
271 profile.dot_seconds += start.elapsed().as_secs_f64();
272 profile.dot_calls += 1;
273 value
274}
275
276fn timed_norm(x: &[f64], profile: &mut PcgStageProfile) -> f64 {
277 let start = Instant::now();
278 let value = norm2(x);
279 profile.norm_seconds += start.elapsed().as_secs_f64();
280 profile.norm_calls += 1;
281 value
282}
283
284fn profiled_pcg(
285 a: &dyn LinearOperator,
286 m: &dyn Preconditioner,
287 b: &[f64],
288 x: &mut [f64],
289 options: SolverOptions,
290) -> Result<(KrylovOutcome, PcgStageProfile, f64), HybitError> {
291 options.validate()?;
292 if a.rows() != a.cols() {
293 return Err(HybitError::InvalidMatrix("PCG requires a square operator"));
294 }
295 let n = a.rows();
296 if b.len() != n {
297 return Err(HybitError::DimensionMismatch {
298 expected: n,
299 actual: b.len(),
300 });
301 }
302 if x.len() != n {
303 return Err(HybitError::DimensionMismatch {
304 expected: n,
305 actual: x.len(),
306 });
307 }
308 if m.len() != n {
309 return Err(HybitError::DimensionMismatch {
310 expected: n,
311 actual: m.len(),
312 });
313 }
314
315 let mut profile = PcgStageProfile::default();
316 let mut ax = vec![0.0; n];
317 let mut r = vec![0.0; n];
318 let mut z = vec![0.0; n];
319 let mut p = vec![0.0; n];
320 let mut ap = vec![0.0; n];
321 let wall_start = Instant::now();
324
325 let start = Instant::now();
326 a.apply(x, &mut ax)?;
327 profile.operator_seconds += start.elapsed().as_secs_f64();
328 profile.operator_calls += 1;
329
330 let start = Instant::now();
331 for i in 0..n {
332 r[i] = b[i] - ax[i];
333 }
334 profile.residual_init_seconds += start.elapsed().as_secs_f64();
335
336 let initial_residual = timed_norm(&r, &mut profile);
337 let b_norm = timed_norm(b, &mut profile);
338 let target = options
339 .absolute_tolerance
340 .max(options.relative_tolerance * b_norm.max(f64::MIN_POSITIVE));
341 if initial_residual <= target {
342 let wall = wall_start.elapsed().as_secs_f64();
343 return Ok((
344 KrylovOutcome {
345 status: SolveStatus::Converged,
346 iterations: 0,
347 initial_residual,
348 final_residual: initial_residual,
349 },
350 profile,
351 wall,
352 ));
353 }
354
355 let start = Instant::now();
356 m.apply(&r, &mut z)?;
357 profile.preconditioner_seconds += start.elapsed().as_secs_f64();
358 profile.preconditioner_calls += 1;
359
360 let start = Instant::now();
361 p.copy_from_slice(&z);
362 profile.copy_p_seconds += start.elapsed().as_secs_f64();
363
364 let mut rz_old = timed_dot(&r, &z, &mut profile);
365 if !rz_old.is_finite() || rz_old <= 0.0 {
366 return Err(HybitError::NumericalBreakdown(
367 "non-positive r^T M^-1 r; PCG assumptions may be violated",
368 ));
369 }
370
371 let mut final_residual = initial_residual;
372 for iter in 1..=options.max_iterations {
373 let start = Instant::now();
374 a.apply(&p, &mut ap)?;
375 profile.operator_seconds += start.elapsed().as_secs_f64();
376 profile.operator_calls += 1;
377
378 let denom = timed_dot(&p, &ap, &mut profile);
379 if !denom.is_finite() || denom <= 0.0 {
380 let wall = wall_start.elapsed().as_secs_f64();
381 return Ok((
382 KrylovOutcome {
383 status: SolveStatus::Breakdown,
384 iterations: iter - 1,
385 initial_residual,
386 final_residual,
387 },
388 profile,
389 wall,
390 ));
391 }
392
393 let alpha = rz_old / denom;
394 let start = Instant::now();
395 for i in 0..n {
396 x[i] += alpha * p[i];
397 r[i] -= alpha * ap[i];
398 }
399 profile.update_x_r_seconds += start.elapsed().as_secs_f64();
400 profile.update_x_r_calls += 1;
401
402 final_residual = timed_norm(&r, &mut profile);
403 if final_residual <= target {
404 let wall = wall_start.elapsed().as_secs_f64();
405 return Ok((
406 KrylovOutcome {
407 status: SolveStatus::Converged,
408 iterations: iter,
409 initial_residual,
410 final_residual,
411 },
412 profile,
413 wall,
414 ));
415 }
416
417 let start = Instant::now();
418 m.apply(&r, &mut z)?;
419 profile.preconditioner_seconds += start.elapsed().as_secs_f64();
420 profile.preconditioner_calls += 1;
421
422 let rz_new = timed_dot(&r, &z, &mut profile);
423 if !rz_new.is_finite() || rz_new <= 0.0 {
424 let wall = wall_start.elapsed().as_secs_f64();
425 return Ok((
426 KrylovOutcome {
427 status: SolveStatus::Breakdown,
428 iterations: iter,
429 initial_residual,
430 final_residual,
431 },
432 profile,
433 wall,
434 ));
435 }
436
437 let beta = rz_new / rz_old;
438 let start = Instant::now();
439 for i in 0..n {
440 p[i] = z[i] + beta * p[i];
441 }
442 profile.update_p_seconds += start.elapsed().as_secs_f64();
443 profile.update_p_calls += 1;
444 rz_old = rz_new;
445 }
446
447 let wall = wall_start.elapsed().as_secs_f64();
448 Ok((
449 KrylovOutcome {
450 status: SolveStatus::MaxIterations,
451 iterations: options.max_iterations,
452 initial_residual,
453 final_residual,
454 },
455 profile,
456 wall,
457 ))
458}
459
460fn print_stage(label: &str, seconds: f64, calls: usize, wall: f64) {
461 let total_ms = seconds * 1.0e3;
462 let per_call_ms = if calls == 0 {
463 0.0
464 } else {
465 total_ms / calls as f64
466 };
467 let share = if wall == 0.0 {
468 0.0
469 } else {
470 100.0 * seconds / wall
471 };
472 println!(
473 "{label:<24}: {:9.3} ms total {:7.3} ms/call {:5.1}% ({} calls)",
474 total_ms, per_call_ms, share, calls
475 );
476}
477
478fn main() -> Result<(), Box<dyn Error>> {
479 let args = Args::parse()?;
480 println!(
481 "HyBIT {} PCG vector-kernel profile",
482 env!("CARGO_PKG_VERSION")
483 );
484 println!("matrix : {}", args.matrix.display());
485 println!("coordinates : {}", args.coordinates.display());
486
487 let load_start = Instant::now();
488 let (matrix, mm) = read_matrix_market(&args.matrix)?;
489 let matrix_load = load_start.elapsed().as_secs_f64();
490 let coord_start = Instant::now();
491 let coordinates = read_coordinates(&args.coordinates)?;
492 let coord_load = coord_start.elapsed().as_secs_f64();
493 let matrix_profile = analyze_csr32(&matrix)?;
494
495 println!(
496 "Matrix Market : {:?}, {} input entries -> {} CSR nnz",
497 mm.symmetry, mm.input_entries, mm.csr_nnz
498 );
499 println!(
500 "dimensions : {} x {}",
501 matrix_profile.nrows, matrix_profile.ncols
502 );
503 println!("nnz : {}", matrix_profile.nnz);
504 println!(
505 "CSR storage : {:.3} MiB",
506 mib(matrix.storage_bytes())
507 );
508 println!("matrix load : {:.3} ms", matrix_load * 1.0e3);
509 println!("coordinate nodes : {}", coordinates.len());
510 println!("coordinate load : {:.3} ms", coord_load * 1.0e3);
511 println!("aggregation : {:?}", args.aggregation);
512 println!("target coarse dim : {}", args.target_coarse_dimension);
513
514 if !matrix_profile.square || !matrix_profile.full_diagonal || !matrix_profile.positive_diagonal
515 {
516 return Err(
517 "structural PCG benchmark requires a square matrix with a complete positive diagonal"
518 .into(),
519 );
520 }
521 if matrix.nrows() != coordinates.len() * 3 {
522 return Err(format!(
523 "matrix/coordinate mismatch: {} matrix rows != {} coordinate nodes * 3",
524 matrix.nrows(),
525 coordinates.len()
526 )
527 .into());
528 }
529
530 let b = if let Some(path) = args.rhs.as_deref() {
531 println!("RHS : {}", path.display());
532 load_rhs(path, matrix.nrows())?
533 } else {
534 println!("RHS : generated as b=A*1 (known exact solution)");
535 matrix.spmv(&vec![1.0; matrix.ncols()])?
536 };
537
538 let aggregate_nodes =
539 recommend_rigid_body_aggregate_nodes(coordinates.len(), args.target_coarse_dimension)?;
540 let setup_start = Instant::now();
541 let preconditioner = match args.aggregation {
542 RigidBodyAggregation::Graph => {
543 RigidBodyTwoLevelBlockJacobiPreconditioner::from_csr32_graph(
544 &matrix,
545 &coordinates,
546 aggregate_nodes,
547 )?
548 }
549 RigidBodyAggregation::Contiguous => RigidBodyTwoLevelBlockJacobiPreconditioner::from_csr32(
550 &matrix,
551 &coordinates,
552 aggregate_nodes,
553 )?,
554 RigidBodyAggregation::Auto => unreachable!(),
555 };
556 let parallel_preconditioner = ParallelRigidBodyTwoLevelPreconditioner::new(&preconditioner)?;
557 let parallel_operator = ParallelCsr32Operator::new(&matrix);
558 let setup_seconds = setup_start.elapsed().as_secs_f64();
559
560 println!("aggregate target : {} nodes", aggregate_nodes);
561 println!("aggregate count : {}", preconditioner.aggregate_count());
562 println!("coarse dimension : {}", preconditioner.coarse_dimension());
563 println!(
564 "prec storage : {:.3} MiB",
565 mib(preconditioner.factor_bytes())
566 );
567 println!(
568 "parallel index : {:.3} MiB",
569 mib(parallel_preconditioner.index_storage_bytes())
570 );
571 println!("setup : {:.3} ms", setup_seconds * 1.0e3);
572 println!(
573 "Rayon threads : {}",
574 parallel_preconditioner.rayon_threads()
575 );
576
577 let options = SolverOptions {
578 relative_tolerance: args.relative_tolerance,
579 absolute_tolerance: 0.0,
580 max_iterations: args.max_iterations,
581 };
582
583 let mut x_profiled = vec![0.0; matrix.ncols()];
584 let (profiled_out, stages, profiled_wall) = profiled_pcg(
585 ¶llel_operator,
586 ¶llel_preconditioner,
587 &b,
588 &mut x_profiled,
589 options,
590 )?;
591 let profiled_verified = verified_relative_residual(&matrix, &b, &x_profiled)?;
592
593 let mut x_control = vec![0.0; matrix.ncols()];
594 let mut workspace = PcgWorkspace::new(matrix.nrows());
595 let control_start = Instant::now();
596 let control_out = pcg_with_workspace(
597 ¶llel_operator,
598 ¶llel_preconditioner,
599 &b,
600 &mut x_control,
601 options,
602 &mut workspace,
603 )?;
604 let control_wall = control_start.elapsed().as_secs_f64();
605 let control_verified = verified_relative_residual(&matrix, &b, &x_control)?;
606
607 println!();
608 println!("Profiled PCG");
609 println!("status : {:?}", profiled_out.status);
610 println!("iterations : {}", profiled_out.iterations);
611 println!(
612 "reported residual : {:.6e}",
613 profiled_out.final_residual / norm2(&b).max(f64::MIN_POSITIVE)
614 );
615 println!("verified residual : {:.6e}", profiled_verified);
616 println!("profiled wall : {:.3} ms", profiled_wall * 1.0e3);
617 println!(
618 "observed / iter : {:.3} ms",
619 if profiled_out.iterations == 0 {
620 0.0
621 } else {
622 profiled_wall * 1.0e3 / profiled_out.iterations as f64
623 }
624 );
625 println!();
626 println!("Actual nested PCG stage timings");
627 print_stage(
628 "operator A*x",
629 stages.operator_seconds,
630 stages.operator_calls,
631 profiled_wall,
632 );
633 print_stage(
634 "preconditioner",
635 stages.preconditioner_seconds,
636 stages.preconditioner_calls,
637 profiled_wall,
638 );
639 print_stage(
640 "dot reductions",
641 stages.dot_seconds,
642 stages.dot_calls,
643 profiled_wall,
644 );
645 print_stage(
646 "norm reductions",
647 stages.norm_seconds,
648 stages.norm_calls,
649 profiled_wall,
650 );
651 print_stage(
652 "x/r fused update",
653 stages.update_x_r_seconds,
654 stages.update_x_r_calls,
655 profiled_wall,
656 );
657 print_stage(
658 "p update",
659 stages.update_p_seconds,
660 stages.update_p_calls,
661 profiled_wall,
662 );
663 print_stage(
664 "initial residual",
665 stages.residual_init_seconds,
666 1,
667 profiled_wall,
668 );
669 print_stage("initial p copy", stages.copy_p_seconds, 1, profiled_wall);
670 let accounted = stages.accounted_seconds();
671 let unaccounted = (profiled_wall - accounted).max(0.0);
672 println!(
673 "accounted total : {:9.3} ms {:5.1}%",
674 accounted * 1.0e3,
675 100.0 * accounted / profiled_wall.max(f64::MIN_POSITIVE)
676 );
677 println!(
678 "timer/control overhead : {:9.3} ms {:5.1}%",
679 unaccounted * 1.0e3,
680 100.0 * unaccounted / profiled_wall.max(f64::MIN_POSITIVE)
681 );
682
683 println!();
684 println!("Uninstrumented control");
685 println!("status : {:?}", control_out.status);
686 println!("iterations : {}", control_out.iterations);
687 println!(
688 "reported residual : {:.6e}",
689 control_out.final_residual / norm2(&b).max(f64::MIN_POSITIVE)
690 );
691 println!("verified residual : {:.6e}", control_verified);
692 println!("solve time : {:.3} ms", control_wall * 1.0e3);
693 println!(
694 "profile/control : {:.3}x",
695 profiled_wall / control_wall.max(f64::MIN_POSITIVE)
696 );
697 println!("note : profile uses the same serial PCG vector kernels as production; only nested timers are added");
698
699 Ok(())
700}