Reworks the post-fetch half of the propagation pipeline. Fetch and GRIB2
decode were already cheap — measured against a live HRRR cycle, all 39
pressure messages decode via `wgrib2 -lola` in 0.29 s and the 31 MB
byte-range fetch takes ~3 s — so nothing here touches the decoder. All the
cost was downstream.
Also fixes a broken NOTIFY that made every chain step run up to 5 times.
pg_notify
`NOTIFY propagation_ready, $1` is a Postgres syntax error: NOTIFY is a
utility statement whose payload must be a literal, so a bind raises
42601. It shared a transaction with the `status='done'` UPDATE, so every
successful step rolled back, stayed 'running', and was requeued by
reclaim_stale_running up to @max_reclaim_attempts times. Elixir's
NotifyListener never fired either, so ScoreCache warm and the
"propagation:updated" fan-out were dead.
FieldGrid
A decoded grid was HashMap<(i32,i32), HashMap<Arc<str>, f32>> — a dense
rectangular grid stored as ~95k nested hash maps, costing ~4.6M inserts
on decode, ~3.7M on merge and ~14M lookups across three derivation
passes. wgrib2 -lola already emits one dense row-major f32 block per
message, so keep it: dense per-message planes, names hashed once per
grid into plane ids, NaN as the missing sentinel. This is what forced
PROP_GRID_RS_PARALLELISM=1 under a 3Gi limit.
Fused pass
Three 95k-cell derivation passes plus 23 band-major scoring passes over
a staged Vec<(f64,f64,Conditions,BandInvariants)> (~19MB re-streamed 23
times) collapse into one pass: levels extracted once per cell, all 23
bands scored while the cell is hot, scores accumulated cell-major so
rayon chunks own disjoint slices. Scores land straight in the dense
score-file body — no ScorePoint scatter.
.pgrid
The profile artifact was an rmpv tree plus gzip -9, written 30x an hour,
and ProfilesFile.read_point/3 gunzipped and unpacked the entire 95k-cell
file to return one cell on every map click and Skew-T load. Replaced
with a dense cell-major f32 record array carrying a self-describing
field table. Elixir reads it via :file.pread; .mp.gz and .etf.gz remain
readable so files written before this drain out of the 48h window.
Measured on a full CONUS grid (95,073 cells x 48 planes x 23 bands):
derive + score + build artifacts 0.022 s
profile write 3.957 s -> 0.006 s (22.0 MB -> 22.4 MB on disk)
single-cell read whole-file decode -> 0.5 us
23 score files 0.003 s
Also
- hrrr_points: batched UNNEST upsert replacing one awaited INSERT per
point. Keeps ON CONFLICT DO UPDATE — the PSKR sampler's two-pass loop
depends on it.
- fetcher: real semaphore capping in-flight ranges at
MAX_PARALLEL_RANGES, which the comment claimed but the code did not do
(it spawned all 27 while the connection pool was sized for 8).
- metrics: per-stage histogram. Only chain-step and decode durations
were instrumented, which is why the write cost stayed invisible.
- profiles_file: parse_valid_time anchors on the known extension set, so
sibling-suffixed names like <iso>.hrdps.prop no longer parse as
<iso>.hrdps and vanish from prune and list operations.
- PROP_GRID_RS_PARALLELISM 1 -> 3. Memory limit held at 3Gi until RSS is
observed at the new parallelism.
- cargo fmt over the crate; worker.rs, hrdps_fetcher.rs and nexrad.rs
were already unformatted at HEAD and the pre-commit hook gates on it.
HRDPS still runs at 0.5 degrees. wgrib2 -lola scales linearly in output
points on rotated lat/lon (12.5 s wall, 202 s CPU for one message at
0.125 degrees) because it has no inverse projection for those grids; a raw
native dump is 0.32 s. The fix is decode-once plus a closed-form
rotated-pole index, left for a follow-up.
141 lines
5.1 KiB
Rust
141 lines
5.1 KiB
Rust
//! Wall-time smoke test for the fused derive+score pass on a
|
||
//! production-sized CONUS grid (473 × 201 = 95,073 cells, 48 planes).
|
||
//!
|
||
//! Ignored by default — it is a timing observation, not an assertion
|
||
//! about the machine it runs on. Run with:
|
||
//!
|
||
//! ```text
|
||
//! cargo test --release --test fused_pass_perf -- --ignored --nocapture
|
||
//! ```
|
||
//!
|
||
//! Context: this pass replaced three 95 k-cell derivation passes plus 23
|
||
//! band-major scoring passes over a staged
|
||
//! `Vec<(f64, f64, Conditions, BandInvariants)>`. The number printed here
|
||
//! is the one to watch when tuning `FUSE_CHUNK` or the scorer.
|
||
|
||
use chrono::{TimeZone, Utc};
|
||
use prop_grid_rs::field_grid::FieldGrid;
|
||
use prop_grid_rs::{fetcher, grid};
|
||
|
||
#[test]
|
||
#[ignore = "timing observation, not a pass/fail assertion"]
|
||
fn fused_pass_on_full_conus_grid() {
|
||
let spec = grid::wgrib2_grid_spec();
|
||
let n = spec.lat_count * spec.lon_count;
|
||
assert_eq!(n, 95_073, "CONUS grid size changed");
|
||
|
||
let mut g = FieldGrid::new(spec);
|
||
|
||
// Surface planes, varied per cell so nothing folds to a constant.
|
||
let ramp = |base: f32, span: f32| -> Vec<f32> {
|
||
(0..n)
|
||
.map(|c| base + ((c % 97) as f32 / 97.0) * span)
|
||
.collect()
|
||
};
|
||
g.push_plane("TMP:2 m above ground", ramp(275.0, 30.0));
|
||
g.push_plane("DPT:2 m above ground", ramp(270.0, 25.0));
|
||
g.push_plane("UGRD:10 m above ground", ramp(-8.0, 16.0));
|
||
g.push_plane("VGRD:10 m above ground", ramp(-8.0, 16.0));
|
||
g.push_plane("TCDC:entire atmosphere", ramp(0.0, 100.0));
|
||
g.push_plane("PRES:surface", ramp(97_000.0, 6_000.0));
|
||
g.push_plane("HPBL:surface", ramp(100.0, 2_000.0));
|
||
g.push_plane(
|
||
"PWAT:entire atmosphere (considered as a single layer)",
|
||
ramp(5.0, 45.0),
|
||
);
|
||
g.push_plane("APCP:surface", ramp(0.0, 3.0));
|
||
|
||
// 13 pressure levels × 3 variables = the 39 pressure planes.
|
||
for (i, k) in fetcher::grid_level_keys().iter().enumerate() {
|
||
let dz = i as f32 * 250.0;
|
||
g.push_plane(&k.tmp, ramp(275.0 - dz * 0.0065, 20.0));
|
||
g.push_plane(&k.dpt, ramp(268.0 - dz * 0.0065, 18.0));
|
||
g.push_plane(&k.hgt, ramp(100.0 + dz, 50.0));
|
||
}
|
||
assert_eq!(g.n_planes(), 48);
|
||
|
||
let valid_time = Utc.with_ymd_and_hms(2026, 6, 15, 18, 0, 0).unwrap();
|
||
|
||
let started = std::time::Instant::now();
|
||
let out = prop_grid_rs::pipeline::derive_and_score_for_bench(&g, valid_time, true, None);
|
||
let elapsed = started.elapsed();
|
||
|
||
println!(
|
||
"fused pass: {n} cells × {} planes × {} bands in {:.3} s ({} scored, {} scalars)",
|
||
g.n_planes(),
|
||
out.band_bodies.len(),
|
||
elapsed.as_secs_f64(),
|
||
out.cells_scored,
|
||
out.scalar_rows.len(),
|
||
);
|
||
|
||
assert_eq!(out.cells_scored, n as u32);
|
||
assert_eq!(out.band_bodies.len(), 23);
|
||
for (_, body) in &out.band_bodies {
|
||
assert_eq!(body.len(), n);
|
||
}
|
||
|
||
// Sanity: the pgrid body must actually carry the 13-level profile,
|
||
// otherwise the timing above is measuring an empty structure.
|
||
let nf = prop_grid_rs::pgrid::n_fields();
|
||
assert_eq!(out.pgrid_body.len(), n * nf);
|
||
let names = prop_grid_rs::pgrid::field_names();
|
||
let t850 = names.iter().position(|f| f == "tmpc_850mb").unwrap();
|
||
assert!(
|
||
!out.pgrid_body[t850].is_nan(),
|
||
"cell 0 should carry an 850 mb temperature"
|
||
);
|
||
|
||
// Now time the on-disk artifacts, which is what remains of the step.
|
||
let dir = tempfile::tempdir().unwrap();
|
||
|
||
let t = std::time::Instant::now();
|
||
let path =
|
||
prop_grid_rs::pgrid::write_atomic(dir.path(), valid_time, &spec, &out.pgrid_body, false)
|
||
.unwrap();
|
||
let profile_write = t.elapsed();
|
||
let profile_bytes = std::fs::metadata(&path).unwrap().len();
|
||
|
||
let t = std::time::Instant::now();
|
||
prop_grid_rs::weather_scalar_file::write_atomic(dir.path(), valid_time, &out.scalar_rows)
|
||
.unwrap();
|
||
let scalar_write = t.elapsed();
|
||
|
||
let t = std::time::Instant::now();
|
||
for (band_mhz, body) in &out.band_bodies {
|
||
prop_grid_rs::scores_file::write_atomic_dense(
|
||
dir.path(),
|
||
*band_mhz,
|
||
valid_time,
|
||
body,
|
||
spec,
|
||
false,
|
||
)
|
||
.unwrap();
|
||
}
|
||
let scores_write = t.elapsed();
|
||
|
||
println!(
|
||
"pgrid write : {:.3} s ({:.1} MB on disk)",
|
||
profile_write.as_secs_f64(),
|
||
profile_bytes as f64 / 1e6
|
||
);
|
||
|
||
// Single-cell read: what /map point-detail and Skew-T actually do.
|
||
// The old `.mp.gz` path gunzipped and unpacked the whole file for this.
|
||
let raw = std::fs::read(&path).unwrap();
|
||
let header = prop_grid_rs::pgrid::decode_header(&raw).expect("header");
|
||
let t = std::time::Instant::now();
|
||
let mut sink = 0.0f32;
|
||
for cell in (0..n).step_by(1_000) {
|
||
let rec = prop_grid_rs::pgrid::read_cell(&raw, &header, cell).unwrap();
|
||
sink += rec[0];
|
||
}
|
||
let reads = (n / 1_000) as f64;
|
||
println!(
|
||
"pgrid read_cell : {:.1} µs/cell over {reads} reads (checksum {sink:.1})",
|
||
t.elapsed().as_secs_f64() * 1e6 / reads
|
||
);
|
||
println!("scalar_file write: {:.3} s", scalar_write.as_secs_f64());
|
||
println!("23 score files : {:.3} s", scores_write.as_secs_f64());
|
||
}
|