mod chunk_cache;
mod config;
use std::path::{Path, PathBuf};
use std::time::Instant;
use anyhow::{anyhow, Context};
use clap::{Parser, Subcommand};
use tomoxide::io::DatasetReader;
use tomoxide::Algorithm;
use tomoxide::Center;
use tomoxide::{Angles, BackendKind, Dtype, Engine, Geometry, ReconParams};
use crate::config::Config;
#[derive(Parser, Debug)]
#[command(name = "tomoxide", version, about)]
struct Cli {
#[arg(long, global = true, default_value = "auto")]
backend: String,
#[arg(long, short, global = true)]
verbose: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
#[command(rename_all = "snake_case")] enum Command {
Init {
#[arg(long, default_value = "tomoxide.toml")]
config: PathBuf,
},
Status {
#[arg(long)]
config: Option<PathBuf>,
},
Recon {
file: PathBuf,
#[arg(long, default_value = "fbp")]
algorithm: String,
#[arg(long)]
center: Option<f32>,
#[arg(long, default_value = "float32")]
dtype: String,
#[arg(long, default_value = "tiff")]
save_format: String,
#[arg(long)]
chunk: Option<usize>,
#[arg(long)]
start_row: Option<usize>,
#[arg(long)]
end_row: Option<usize>,
#[arg(long)]
lamino_angle: Option<f32>,
#[arg(long)]
lamino_rh: Option<usize>,
},
ReconSteps {
file: PathBuf,
#[arg(long, default_value = "fbp")]
algorithm: String,
#[arg(long)]
center: Option<f32>,
#[arg(long, default_value = "float32")]
dtype: String,
#[arg(long, default_value = "tiff")]
save_format: String,
#[arg(long, default_value_t = DEFAULT_PIPELINE_CHUNK)]
chunk: usize,
},
TuneChunk {
file: PathBuf,
#[arg(long, default_value = "fbp")]
algorithm: String,
#[arg(long)]
center: Option<f32>,
#[arg(long, default_value = "float32")]
dtype: String,
},
}
const DEFAULT_PIPELINE_CHUNK: usize = 8;
const MULTI_GPU_MIN_NX: usize = 2048;
fn pipelines_well(engine: &Engine, algo: Algorithm) -> bool {
engine.name() == "cuda"
&& matches!(
algo,
Algorithm::Fbp | Algorithm::Linerec | Algorithm::Fourierrec | Algorithm::Lprec
)
}
fn parse_backend(s: &str) -> anyhow::Result<BackendKind> {
Ok(match s {
"auto" => BackendKind::Auto,
"cpu" => BackendKind::Cpu,
"cuda" => BackendKind::Cuda,
"wgpu" => BackendKind::Wgpu,
other => return Err(anyhow!("unknown backend '{other}' (auto|cpu|cuda|wgpu)")),
})
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let level = if cli.verbose { "debug" } else { "info" };
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(level)).init();
let backend_kind = parse_backend(&cli.backend)?;
match cli.command {
Command::Init { config } => {
let cfg = Config::default();
cfg.write(&config)
.with_context(|| format!("writing {}", config.display()))?;
println!("wrote default config to {}", config.display());
}
Command::Status { config } => {
let engine = Engine::new(backend_kind)?;
println!("tomoxide {}", env!("CARGO_PKG_VERSION"));
println!("backend (requested {}): {}", cli.backend, engine.name());
if let Some(path) = config {
let cfg =
Config::load(&path).with_context(|| format!("loading {}", path.display()))?;
println!("config: {cfg:#?}");
}
}
Command::Recon {
file,
algorithm,
center,
dtype,
save_format,
chunk,
start_row,
end_row,
lamino_angle,
lamino_rh,
} => {
let engine = Engine::new(backend_kind)?;
let algo: Algorithm = algorithm.parse().map_err(|e| anyhow!("{e}"))?;
let dtype: Dtype = dtype.parse().map_err(|e| anyhow!("{e}"))?;
let save_format_str = save_format.clone();
let save_format: tomoxide::io::SaveFormat =
save_format.parse().map_err(|e| anyhow!("{e}"))?;
println!(
"recon: file={} algorithm={:?} center={:?} dtype={} backend={}",
file.display(),
algo,
center,
dtype.as_str(),
engine.name()
);
let out = recon_out_path(&file);
if pipelines_well(&engine, algo) && lamino_angle.is_none() {
let mut probe = tomoxide::io::open_dxchange(&file.to_string_lossy())?;
let (nproj, nz, nx, _nflat, _ndark) = probe.read_sizes()?;
drop(probe);
let (chunk, source) = resolve_chunk(chunk, &file, &algorithm, dtype, nx, nproj, nz);
println!(" chunk: {chunk} ({source})");
let devices = tomoxide::cuda::selected_devices();
let top_level = start_row.is_none() && end_row.is_none();
let shardable = engine.name() == "cuda"
&& matches!(save_format, tomoxide::io::SaveFormat::Tiff)
&& top_level
&& devices.len() > 1
&& nz > devices.len()
&& nx >= MULTI_GPU_MIN_NX;
if shardable {
run_sharded_subprocesses(
&file,
&algorithm,
center,
dtype,
&save_format_str,
chunk,
nz,
&devices,
)?;
} else {
run_pipelined(
&file,
&out,
algo,
center,
dtype,
save_format,
chunk,
start_row,
end_row,
&engine,
)?;
}
} else {
let mut reader = tomoxide::io::open_dxchange(&file.to_string_lossy())?;
let mut geom = geometry_from_reader(reader.as_mut(), center)?;
let mut params = recon_params(&geom, dtype);
if let Some(deg) = lamino_angle {
use std::f32::consts::PI;
geom.beam = tomoxide::Beam::Laminography {
phi: PI / 2.0 + deg * PI / 180.0,
};
params.lamino_rh = lamino_rh;
println!(" laminography: tilt={deg}° rh={lamino_rh:?}");
}
let ds = reader.read_all()?;
let vol = tomoxide::reconstruct(
ds,
&geom,
algo,
¶ms,
&tomoxide::PrepOptions::default(),
&engine,
)?;
let mut writer = tomoxide::io::create_writer(&out, save_format)?;
let nz = vol.dims().0;
writer.reserve(nz)?;
writer.write_chunk(&vol, 0, nz)?;
}
println!("wrote reconstruction to {out}");
}
Command::ReconSteps {
file,
algorithm,
center,
dtype,
save_format,
chunk,
} => {
let engine = Engine::new(backend_kind)?;
let algo: Algorithm = algorithm.parse().map_err(|e| anyhow!("{e}"))?;
let dtype: Dtype = dtype.parse().map_err(|e| anyhow!("{e}"))?;
let save_format: tomoxide::io::SaveFormat =
save_format.parse().map_err(|e| anyhow!("{e}"))?;
println!(
"recon_steps: file={} algorithm={:?} center={:?} dtype={} chunk={} backend={}",
file.display(),
algo,
center,
dtype.as_str(),
chunk,
engine.name()
);
let out = recon_out_path(&file);
run_pipelined(
&file,
&out,
algo,
center,
dtype,
save_format,
chunk,
None,
None,
&engine,
)?;
println!("wrote streamed reconstruction to {out}");
}
Command::TuneChunk {
file,
algorithm,
center,
dtype,
} => {
let engine = Engine::new(backend_kind)?;
let algo: Algorithm = algorithm.parse().map_err(|e| anyhow!("{e}"))?;
let dtype: Dtype = dtype.parse().map_err(|e| anyhow!("{e}"))?;
if !pipelines_well(&engine, algo) {
return Err(anyhow!(
"tune_chunk applies only to CUDA pipelined algorithms \
(fbp, linerec, fourierrec, lprec); {:?} on backend {} uses the \
whole-volume path, where --chunk has no effect",
algo,
engine.name()
));
}
tune_chunk(&file, &algorithm, algo, center, dtype, &engine)?;
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn run_pipelined(
file: &Path,
out: &str,
algo: Algorithm,
center: Option<f32>,
dtype: Dtype,
save_format: tomoxide::io::SaveFormat,
chunk: usize,
start_row: Option<usize>,
end_row: Option<usize>,
engine: &Engine,
) -> anyhow::Result<()> {
let path = file.to_string_lossy().into_owned();
let mut probe = tomoxide::io::open_dxchange(&path)?;
let geom = geometry_from_reader(probe.as_mut(), center)?;
drop(probe);
let params = recon_params(&geom, dtype);
let read_path = path;
let write_path = out.to_string();
let z_start = start_row.unwrap_or(0);
let z_end = end_row.unwrap_or(usize::MAX);
tomoxide::ReconSteps::new(chunk).run_streaming_pipelined_range(
z_start,
z_end,
move || tomoxide::io::open_dxchange(&read_path),
move || tomoxide::io::create_writer(&write_path, save_format),
&geom,
algo,
¶ms,
&tomoxide::PrepOptions::default(),
engine,
)?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn run_sharded_subprocesses(
file: &Path,
algorithm: &str,
center: Option<f32>,
dtype: Dtype,
save_format: &str,
chunk: usize,
nz: usize,
devices: &[i32],
) -> anyhow::Result<()> {
let exe = std::env::current_exe().context("locating current executable")?;
let n = devices.len();
let base = nz / n;
let rem = nz % n;
println!(" multi-GPU: {n} shards across devices {devices:?}");
let mut children = Vec::with_capacity(n);
let mut z0 = 0usize;
for (i, &dev) in devices.iter().enumerate() {
let rows = base + if i < rem { 1 } else { 0 };
let z1 = z0 + rows;
let mut cmd = std::process::Command::new(&exe);
cmd.arg("--backend")
.arg("cuda")
.arg("recon")
.arg(file)
.arg("--algorithm")
.arg(algorithm)
.arg("--dtype")
.arg(dtype.as_str())
.arg("--save_format")
.arg(save_format)
.arg("--chunk")
.arg(chunk.to_string())
.arg("--start_row")
.arg(z0.to_string())
.arg("--end_row")
.arg(z1.to_string());
if let Some(c) = center {
cmd.arg("--center").arg(c.to_string());
}
cmd.env("CUDA_VISIBLE_DEVICES", dev.to_string())
.env("TOMOXIDE_CUDA_DEVICES", "0")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped());
let child = cmd
.spawn()
.with_context(|| format!("spawning shard {i} on device {dev}"))?;
children.push((i, dev, z0, z1, child));
z0 = z1;
}
let mut failures = Vec::new();
for (i, dev, s, e, child) in children {
let output = child
.wait_with_output()
.with_context(|| format!("waiting for shard {i} on device {dev}"))?;
if !output.status.success() {
let reason = subprocess_failure_reason(&output);
failures.push(format!(
"shard {i} (device {dev}, rows [{s}, {e})): {reason}"
));
}
}
if !failures.is_empty() {
return Err(anyhow!(
"multi-GPU recon failed:\n {}",
failures.join("\n ")
));
}
Ok(())
}
fn resolve_chunk(
explicit: Option<usize>,
file: &Path,
algorithm: &str,
dtype: Dtype,
nx: usize,
nproj: usize,
nz: usize,
) -> (usize, &'static str) {
if let Some(c) = explicit {
return (c.max(1), "--chunk");
}
let gpu = tomoxide::cuda::device_name().unwrap_or_else(|| "unknown".into());
let key = chunk_cache::key(file, algorithm, dtype.as_str(), &gpu);
if let Some(c) = chunk_cache::ChunkCache::load().get(&key, nx, nproj, nz) {
return (c, "from cache");
}
(DEFAULT_PIPELINE_CHUNK, "default")
}
fn chunk_candidates(nz: usize) -> Vec<usize> {
let mut c = Vec::new();
let mut k = DEFAULT_PIPELINE_CHUNK;
while k <= nz / 2 {
c.push(k);
k *= 2;
}
if c.is_empty() {
c.push(nz.max(1));
}
c
}
fn tune_scratch_dir(file: &Path) -> PathBuf {
let parent = file
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
parent.join(format!(".tomoxide_tune_{}", std::process::id()))
}
#[allow(clippy::too_many_arguments)]
fn measure_chunk_subprocess(
exe: &Path,
backend: &str,
link: &Path,
algorithm: &str,
center: Option<f32>,
dtype: Dtype,
chunk: usize,
) -> anyhow::Result<f64> {
let mut cmd = std::process::Command::new(exe);
cmd.arg("--backend")
.arg(backend)
.arg("recon")
.arg(link)
.arg("--algorithm")
.arg(algorithm)
.arg("--dtype")
.arg(dtype.as_str())
.arg("--chunk")
.arg(chunk.to_string());
if let Some(c) = center {
cmd.arg("--center").arg(c.to_string());
}
cmd.env("TOMOXIDE_CUDA_DEVICES", "0")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped());
let t = Instant::now();
let output = cmd
.output()
.with_context(|| format!("spawning {} recon", exe.display()))?;
let secs = t.elapsed().as_secs_f64();
if !output.status.success() {
return Err(anyhow!("{}", subprocess_failure_reason(&output)));
}
Ok(secs)
}
fn subprocess_failure_reason(output: &std::process::Output) -> String {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(sig) = output.status.signal() {
return format!("killed by signal {sig} (does not fit in device memory)");
}
}
let stderr = String::from_utf8_lossy(&output.stderr);
if let Some(e) = stderr
.lines()
.rev()
.find(|l| l.trim_start().starts_with("Error:"))
{
return e
.trim_start()
.trim_start_matches("Error:")
.trim()
.to_string();
}
stderr
.lines()
.rev()
.map(str::trim)
.find(|l| !l.is_empty() && !l.starts_with('['))
.unwrap_or("recon subprocess failed")
.to_string()
}
fn tune_chunk(
file: &Path,
algorithm: &str,
algo: Algorithm,
center: Option<f32>,
dtype: Dtype,
engine: &Engine,
) -> anyhow::Result<()> {
let mut probe = tomoxide::io::open_dxchange(&file.to_string_lossy())?;
let (nproj, nz, nx, _nflat, _ndark) = probe.read_sizes()?;
drop(probe);
let gpu = tomoxide::cuda::device_name().unwrap_or_else(|| "unknown".into());
let candidates = chunk_candidates(nz);
println!(
"tune_chunk: file={} algorithm={:?} dtype={} gpu={} dims=(nx={} nproj={} nz={})",
file.display(),
algo,
dtype.as_str(),
gpu,
nx,
nproj,
nz
);
println!(" candidates (powers of two, ≥2 chunks): {candidates:?}");
let exe = std::env::current_exe().context("locating the tomoxide executable")?;
let scratch = tune_scratch_dir(file);
std::fs::create_dir_all(&scratch)
.with_context(|| format!("creating scratch dir {}", scratch.display()))?;
let link = scratch.join("in.h5");
std::fs::hard_link(file, &link)
.with_context(|| format!("hard-linking {} -> {}", file.display(), link.display()))?;
let cand_out = recon_out_path(&link);
let mut results: Vec<(usize, f64)> = Vec::new();
for &c in &candidates {
match measure_chunk_subprocess(&exe, engine.name(), &link, algorithm, center, dtype, c) {
Ok(secs) => {
println!(" chunk={c:>4}: {secs:.2}s (wall, incl. process+CUDA init)");
results.push((c, secs));
}
Err(e) => println!(" chunk={c:>4}: skipped ({e})"),
}
let _ = std::fs::remove_dir_all(&cand_out);
let _ = std::fs::remove_file(&cand_out);
}
let _ = std::fs::remove_dir_all(&scratch);
let (best_chunk, best_secs) = results
.iter()
.copied()
.min_by(|a, b| a.1.total_cmp(&b.1))
.ok_or_else(|| anyhow!("all chunk candidates failed to run (see skip reasons above)"))?;
let key = chunk_cache::key(file, algorithm, dtype.as_str(), &gpu);
let mut cache = chunk_cache::ChunkCache::load();
cache.insert(
key,
chunk_cache::Entry {
chunk: best_chunk,
nx,
nproj,
nz,
},
);
cache.save()?;
println!(
" best chunk = {best_chunk} ({best_secs:.2}s) → cached to {}",
chunk_cache::CACHE_FILE
);
Ok(())
}
fn geometry_from_reader(
reader: &mut dyn DatasetReader,
center: Option<f32>,
) -> anyhow::Result<Geometry> {
let (_nproj, nz, nx, _nflat, _ndark) = reader.read_sizes()?;
let theta = reader.read_theta()?;
let mut geom = Geometry::parallel(Angles(theta), nx, nz, 1.0);
if let Some(c) = center {
geom.center = Center::Scalar(c);
}
Ok(geom)
}
fn recon_params(geom: &Geometry, dtype: Dtype) -> ReconParams {
ReconParams {
num_gridx: Some(geom.detector.width),
dtype,
..Default::default()
}
}
fn recon_out_path(file: &Path) -> String {
format!("{}_rec", file.with_extension("").display())
}