Show Code
import os
import sys
sys.path.append("..")
import report_utils as ru
from IPython.display import display, HTML, Markdown
display(HTML(f"<div style='margin-bottom: 20px;'>{ru.nav_bar('scaling')}</div>"))Throughput, Particle Scaling, and Chain Scaling on GPU
import os
import sys
sys.path.append("..")
import report_utils as ru
from IPython.display import display, HTML, Markdown
display(HTML(f"<div style='margin-bottom: 20px;'>{ru.nav_bar('scaling')}</div>"))This benchmark explores the computational scaling of pypomp’s mif and pfilter methods on the S&P 500 (SPX) stochastic volatility model (Sun 2024).
We examine scaling along two axes:
import json
import pandas as pd
from plotnine import (
ggplot, aes, geom_line, geom_point, labs, scale_y_continuous,
scale_x_continuous, facet_wrap
)
results_dir = os.path.join("results", "gpu")
scaling_csv_path = os.path.join(results_dir, "scaling.csv")
particle_csv_path = os.path.join(results_dir, "particle_scaling.csv")
chain_csv_path = os.path.join(results_dir, "chain_scaling.csv")
PLATFORMS = {
"pypomp (GPU)": os.path.join("results", "gpu"),
}
runs = ru.load_timing_data(PLATFORMS)
display(HTML(ru.build_settings_comparison_html(runs, is_panel=False)))| Setting / Parameter | pypomp (GPU) |
|---|---|
| Algorithmic & Workload Settings | |
| Run Level | 4 |
| IF2 Iterations ($N_{iter}$) | 50 |
| Evaluation Replicates ($N_{reps}$) | 36 |
| Random Seed | 631409 |
| Software & Environment | |
| Pomp Framework | pypomp 1.0.0rc1 |
| Backend / Engine | JAX 0.11.1 |
| Quant Git Commit | 630cc23 |
| Run Timestamp | 2026-09-02 19:11:02 |
| Hardware & Compute | |
| Compute Device | NVIDIA RTX PRO 6000 Blackwell Server Edition (1 GPU) |
| Slurm Partition | gpu-rtx6000 |
| Slurm Job ID | 59673768 |
missing = [(label, r["dir"]) for label, r in runs.items() if not r["available"]]
for label, path in missing:
display(HTML(f"<div class='alert alert-warning'><strong>Missing results for {label}:</strong> Expected at <code>{path}</code>.</div>"))Holding the number of search chains fixed, we evaluate how IF2 execution time and pfilter runtime scale with particle count \(J\).
has_data = os.path.exists(particle_csv_path)
if has_data:
df_part = pd.read_csv(particle_csv_path).sort_values("J").reset_index(drop=True)
# Compute per-particle unit cost and incremental marginal rate
df_part["cost_per_part_us"] = (df_part["mif_per_iter_seconds"] / df_part["total_particles_mif"]) * 1e6
inc = (df_part["mif_per_iter_seconds"].diff() / df_part["total_particles_mif"].diff()) * 1e6
df_part["incremental_rate_us"] = inc.map(lambda v: f"{v:.4f}" if pd.notna(v) else "—")
df_part["cost_per_part_us_str"] = df_part["cost_per_part_us"].map(lambda v: f"{v:.4f}")
tbl = df_part[[
"J", "chains", "total_particles_mif", "mif_time_seconds",
"mif_per_iter_seconds", "cost_per_part_us_str", "incremental_rate_us",
"pfilter_cold_seconds", "pfilter_warm_seconds"
]].rename(columns={
"J": "Particles (J)",
"chains": "Chains",
"total_particles_mif": "Total IF2 Particles",
"mif_time_seconds": "IF2 Total (s)",
"mif_per_iter_seconds": "IF2 Per Iter (s)",
"cost_per_part_us_str": "Cost/Part (μs/iter)",
"incremental_rate_us": "Incremental Rate (μs)",
"pfilter_cold_seconds": "Pfilter Cold (s)",
"pfilter_warm_seconds": "Pfilter Warm (s)",
})
display(HTML(tbl.to_html(classes="table table-striped table-hover", index=False)))
else:
display(Markdown("_No particle scaling data available._"))| Particles (J) | Chains | Total IF2 Particles | IF2 Total (s) | IF2 Per Iter (s) | Cost/Part (μs/iter) | Incremental Rate (μs) | Pfilter Cold (s) | Pfilter Warm (s) |
|---|---|---|---|---|---|---|---|---|
| 5000 | 120 | 600000 | 38.399399 | 0.767988 | 1.2800 | — | 23.043706 | 21.566166 |
| 10000 | 120 | 1200000 | 54.037043 | 1.080741 | 0.9006 | 0.5213 | 30.763025 | 30.874050 |
| 15000 | 120 | 1800000 | 73.661603 | 1.473232 | 0.8185 | 0.6542 | 38.316685 | 36.832427 |
| 20000 | 120 | 2400000 | 103.751759 | 2.075035 | 0.8646 | 1.0030 | 48.005086 | 46.507267 |
| 30000 | 120 | 3600000 | 162.877982 | 3.257560 | 0.9049 | 0.9854 | 70.349759 | 68.778327 |
| 40000 | 120 | 4800000 | 251.970599 | 5.039412 | 1.0499 | 1.4849 | 95.815490 | 94.216554 |
if has_data:
p1 = (
ggplot(df_part, aes(x="J", y="mif_time_seconds"))
+ geom_line(color="#1abc9c", size=1.2)
+ geom_point(color="#16a085", size=3)
+ labs(
title="IF2 Runtime vs. Particle Count (J)",
subtitle="Fixed chains; runtime across particle counts",
x="Particles per Chain (J)",
y="IF2 Wall-clock Time (s)"
)
+ ru.theme_premium
)
display(p1)
p2 = (
ggplot(df_part, aes(x="J", y="pfilter_warm_seconds"))
+ geom_line(color="#3498db", size=1.2)
+ geom_point(color="#2980b9", size=3)
+ labs(
title="Warm Pfilter Runtime vs. Particle Count (J)",
subtitle="Evaluation runtime across fixed replicates",
x="Particles per Chain (J)",
y="Pfilter Warm Time (s)"
)
+ ru.theme_premium
)
display(p2)Holding particle count fixed at \(J=1\,000\), we evaluate how scaling the number of parallel global search chains impacts IF2 and filter throughput.
has_chain_data = os.path.exists(chain_csv_path)
if has_chain_data:
df_chain = pd.read_csv(chain_csv_path).sort_values("chains").reset_index(drop=True)
# Compute per-particle unit cost and incremental marginal rate
df_chain["cost_per_part_us"] = (df_chain["mif_per_iter_seconds"] / df_chain["total_particles_mif"]) * 1e6
inc_c = (df_chain["mif_per_iter_seconds"].diff() / df_chain["total_particles_mif"].diff()) * 1e6
df_chain["incremental_rate_us"] = inc_c.map(lambda v: f"{v:.4f}" if pd.notna(v) else "—")
df_chain["cost_per_part_us_str"] = df_chain["cost_per_part_us"].map(lambda v: f"{v:.4f}")
tbl_c = df_chain[[
"chains", "J", "total_particles_mif", "mif_time_seconds",
"mif_per_iter_seconds", "cost_per_part_us_str", "incremental_rate_us",
"pfilter_cold_seconds", "pfilter_warm_seconds"
]].rename(columns={
"chains": "Chains",
"J": "Particles (J)",
"total_particles_mif": "Total IF2 Particles",
"mif_time_seconds": "IF2 Total (s)",
"mif_per_iter_seconds": "IF2 Per Iter (s)",
"cost_per_part_us_str": "Cost/Part (μs/iter)",
"incremental_rate_us": "Incremental Rate (μs)",
"pfilter_cold_seconds": "Pfilter Cold (s)",
"pfilter_warm_seconds": "Pfilter Warm (s)",
})
display(HTML(tbl_c.to_html(classes="table table-striped table-hover", index=False)))
else:
display(Markdown("_No chain scaling data available._"))| Chains | Particles (J) | Total IF2 Particles | IF2 Total (s) | IF2 Per Iter (s) | Cost/Part (μs/iter) | Incremental Rate (μs) | Pfilter Cold (s) | Pfilter Warm (s) |
|---|---|---|---|---|---|---|---|---|
| 250 | 1000 | 250000 | 23.305352 | 0.466107 | 1.8644 | — | 14.645051 | 13.265099 |
| 500 | 1000 | 500000 | 32.153880 | 0.643078 | 1.2862 | 0.7079 | 18.731333 | 17.257465 |
| 750 | 1000 | 750000 | 35.714490 | 0.714290 | 0.9524 | 0.2848 | 20.679376 | 19.179101 |
| 1000 | 1000 | 1000000 | 41.398341 | 0.827967 | 0.8280 | 0.4547 | 23.855122 | 22.633046 |
| 1500 | 1000 | 1500000 | 53.125184 | 1.062504 | 0.7083 | 0.4691 | 27.738115 | 26.254754 |
| 2000 | 1000 | 2000000 | 68.245028 | 1.364901 | 0.6825 | 0.6048 | 32.448855 | 30.925807 |
if has_chain_data:
p3 = (
ggplot(df_chain, aes(x="chains", y="mif_time_seconds"))
+ geom_line(color="#e67e22", size=1.2)
+ geom_point(color="#d35400", size=3)
+ labs(
title="IF2 Runtime vs. Number of Parallel Chains",
subtitle="Fixed J=1000 particles; vectorized chain execution",
x="Number of Parallel Chains",
y="IF2 Wall-clock Time (s)"
)
+ ru.theme_premium
)
display(p3)Comparing the efficiency of scaling particle count (\(J\)) versus scaling parallel chains (\(\text{chains}\)) as a function of the total simultaneous particles in flight (\(N_{\text{total}} = J \times \text{chains}\)):
if os.path.exists(scaling_csv_path):
df_all = pd.read_csv(scaling_csv_path)
df_all["scaling_dimension"] = df_all["scaling_type"].map({
"particles": "Varying J (fixed chains)",
"chains": "Varying Chains (fixed J)"
})
p_comp = (
ggplot(df_all, aes(x="total_particles_mif", y="mif_per_iter_seconds", color="scaling_dimension"))
+ geom_line(size=1.2)
+ geom_point(size=3)
+ labs(
title="IF2 Scaling Efficiency Comparison",
subtitle="Per-iteration execution time vs. Total particles in flight (J × Chains)",
x="Total IF2 Particles (J × Chains)",
y="IF2 Time per Iteration (s)",
color="Scaling Experiment"
)
+ ru.theme_premium
)
display(p_comp)Evaluates GPU memory allocation and buffer scaling under dynamic memory allocation. Each grid point is measured in an isolated subprocess with a fresh XLA allocator to capture exact per-configuration peak VRAM usage without cumulative process carryover.
mem_results_dir = os.path.join("results", "gpu")
mem_scaling_csv = os.path.join(mem_results_dir, "scaling.csv")
has_mem_data = os.path.exists(mem_scaling_csv)
if has_mem_data:
df_mem = pd.read_csv(mem_scaling_csv)
df_mem["scaling_dimension"] = df_mem["scaling_type"].map({
"particles": "Varying J (fixed chains)",
"chains": "Varying Chains (fixed J)"
})
# Surface error/OOM rows if present
if "error" in df_mem.columns:
error_rows = df_mem[df_mem["error"].notna() & (df_mem["error"] != "")]
if not error_rows.empty:
err_items = "".join(
f"<li><strong>{r.get('scaling_type', 'config')} (J={r.get('J')}, chains={r.get('chains')}):</strong> {r.get('error')}</li>"
for _, r in error_rows.iterrows()
)
display(HTML(f"<div class='alert alert-warning'><strong>Configuration Errors / OOMs:</strong><ul>{err_items}</ul></div>"))
# Display table of VRAM measurements
if "peak_vram_mb" in df_mem.columns:
cols_to_show = [
"scaling_type", "J", "chains", "total_particles_mif",
"baseline_vram_mb", "peak_vram_mif_mb", "peak_vram_pfilter_mb", "peak_vram_mb"
]
existing_cols = [c for c in cols_to_show if c in df_mem.columns]
rename_map = {
"scaling_type": "Experiment",
"J": "Particles (J)",
"chains": "Chains",
"total_particles_mif": "Total Particles",
"baseline_vram_mb": "Baseline VRAM (MB)",
"peak_vram_mif_mb": "IF2 Peak (MB)",
"peak_vram_pfilter_mb": "Pfilter Peak (MB)",
"peak_vram_mb": "Peak VRAM (MB)",
}
df_valid = df_mem.dropna(subset=["peak_vram_mb"]).copy()
for col in ["baseline_vram_mb", "peak_vram_mif_mb", "peak_vram_pfilter_mb", "peak_vram_mb"]:
if col in df_valid.columns:
df_valid[col] = df_valid[col].map(lambda v: f"{v:.1f}" if pd.notna(v) else "—")
tbl_mem = df_valid[existing_cols].rename(columns=rename_map)
display(HTML(tbl_mem.to_html(classes="table table-striped table-hover", index=False)))
p_mem = (
ggplot(df_mem.dropna(subset=["peak_vram_mb"]), aes(x="total_particles_mif", y="peak_vram_mb", color="scaling_dimension"))
+ geom_line(size=1.2)
+ geom_point(size=3)
+ labs(
title="Peak GPU VRAM Usage vs. Total Simultaneous Particles",
subtitle="Live JAX buffer allocations in MB without VRAM preallocation (isolated subprocess per point)",
x="Total Particles (J × Chains)",
y="Peak VRAM Allocated (MB)",
color="Scaling Experiment"
)
+ ru.theme_premium
)
display(p_mem)
else:
display(Markdown("_Memory measurements not present in scaling results._"))
else:
display(HTML("""
<div class='alert alert-info'>
<strong>GPU benchmark not run yet:</strong> Run <code>python scripts/run_tests.py run tests/spx/scaling/run.py --job gpu --run-level 4</code> to generate runtime and dynamic VRAM scaling measurements.
</div>
"""))| Experiment | Particles (J) | Chains | Total Particles | Baseline VRAM (MB) | IF2 Peak (MB) | Pfilter Peak (MB) | Peak VRAM (MB) |
|---|---|---|---|---|---|---|---|
| particles | 5000 | 120 | 600000 | 0.0 | 128.1 | 32.1 | 128.1 |
| particles | 10000 | 120 | 1200000 | 0.0 | 224.3 | 64.1 | 256.1 |
| particles | 15000 | 120 | 1800000 | 0.0 | 297.3 | 128.1 | 354.5 |
| particles | 20000 | 120 | 2400000 | 0.0 | 448.3 | 128.1 | 448.3 |
| particles | 30000 | 120 | 3600000 | 0.0 | 610.5 | 256.1 | 692.9 |
| particles | 40000 | 120 | 4800000 | 0.0 | 713.8 | 256.1 | 842.0 |
| chains | 1000 | 250 | 250000 | 0.0 | 64.1 | 16.2 | 82.4 |
| chains | 1000 | 500 | 500000 | 0.0 | 112.8 | 32.3 | 128.1 |
| chains | 1000 | 750 | 750000 | 0.0 | 195.5 | 32.5 | 195.5 |
| chains | 1000 | 1000 | 1000000 | 0.0 | 226.2 | 64.6 | 226.2 |
| chains | 1000 | 1500 | 1500000 | 0.0 | 390.9 | 64.9 | 390.9 |
| chains | 1000 | 2000 | 2000000 | 0.0 | 450.9 | 129.2 | 450.9 |