perf(rust): intern CellValues keys as Arc<str> to kill per-cell String clones

The decoder's `parse_lola_binary` inserts the same "VAR:LEVEL"
string into ~92k cell maps for every one of ~60 GRIB2 messages in
a chain step — previously a String::clone per cell, yielding
~5.5M heap allocations and frees per hourly run. Switching
`CellValues` from `HashMap<String, f32>` to
`HashMap<Arc<str>, f32>` makes each insertion a single atomic
refcount increment: the string is allocated once per message, and
`Arc::clone` is cheap from there.

Callers were mostly unchanged — `cell.get("...")` still works via
`Arc<str>: Borrow<str>`. Two touch-ups needed: native_duct.rs had
a couple of `cell.get(&format!(...))` lookups that were passing
`&String` (triggering the wrong Borrow impl) — switched to
.as_str(), and the test fixtures' `.insert(format!(...), _)` now
need a `.into()` to coerce into `Arc<str>`. Zero behavior change.
This commit is contained in:
Graham McIntire 2026-04-21 17:51:39 -05:00
parent 4d5b5f1aee
commit 83aa3115df
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 31 additions and 11 deletions

View file

@ -40,7 +40,17 @@ pub struct Message {
/// Per-cell: `"VAR:LEVEL" -> f32`. Key format matches Elixir exactly so
/// scorer input can be wired unchanged.
pub type CellValues = HashMap<String, f32>;
///
/// Keys are `Arc<str>` rather than `String`: the hot decode loop
/// inserts the same `"VAR:LEVEL"` string into ~92k cell maps per
/// GRIB2 message, which was previously a String::clone per cell
/// (~5.5M heap allocations + frees per chain step across 60
/// messages). `Arc::clone` is a single atomic increment — keys
/// allocate once per message and are ref-counted thereafter.
///
/// Lookup is unchanged: `cell.get("TMP:2 m above ground")` works
/// because `Arc<str>: Borrow<str>`.
pub type CellValues = HashMap<std::sync::Arc<str>, f32>;
/// Lat/lon rounded to 3 decimals. f64 keys can't be hashed directly, so
/// we key by `(lat_mdeg, lon_mdeg)` where `*_mdeg = round(x * 1000) as i32`.
@ -224,7 +234,10 @@ pub fn parse_lola_binary(bin: &[u8], messages: &[Message], grid_spec: &GridSpec)
continue;
}
let chunk = &bin[data_offset..data_offset + bytes_per_msg];
let key = format!("{}:{}", msg.var, msg.level);
// Allocate the "VAR:LEVEL" string once per message. Every
// subsequent cell insertion is Arc::clone (one atomic inc),
// not a String::clone (heap alloc + copy + free).
let key: std::sync::Arc<str> = format!("{}:{}", msg.var, msg.level).into();
for j in 0..ny {
let lat = round3(grid_spec.lat_start + j as f64 * grid_spec.lat_step);
@ -240,7 +253,7 @@ pub fn parse_lola_binary(bin: &[u8], messages: &[Message], grid_spec: &GridSpec)
let lon_key = (lon * 1000.0).round() as i32;
out.entry((lat_key, lon_key))
.or_default()
.insert(key.clone(), value);
.insert(std::sync::Arc::clone(&key), value);
}
}
}

View file

@ -127,18 +127,25 @@ pub fn build_native_profile(cell: &CellValues) -> Option<NativeProfile> {
// or TMP is absent — those levels are junk.
let mut levels: Vec<(f64, f64, f64, f64)> = Vec::with_capacity(NATIVE_LEVEL_COUNT as usize);
for level in 1..=NATIVE_LEVEL_COUNT {
// `cell.get` dispatches via Arc<str>'s Borrow<str>, so pass
// &str refs built by `format!(…).as_str()` (or inline &str
// concat) — passing `&String` triggers the wrong Borrow impl.
let lvl_str = format!("{level} hybrid level");
let Some(&hgt) = cell.get(&format!("HGT:{lvl_str}")) else {
let hgt_key = format!("HGT:{lvl_str}");
let tmp_key = format!("TMP:{lvl_str}");
let spfh_key = format!("SPFH:{lvl_str}");
let pres_key = format!("PRES:{lvl_str}");
let Some(&hgt) = cell.get(hgt_key.as_str()) else {
continue;
};
let Some(&tmp) = cell.get(&format!("TMP:{lvl_str}")) else {
let Some(&tmp) = cell.get(tmp_key.as_str()) else {
continue;
};
// SPFH / PRES default to 0.0 if absent — the duct math handles
// degenerate rows (returns 0.0 gradient / no duct) without
// raising, matching Elixir's nil-tolerance.
let spfh = cell.get(&format!("SPFH:{lvl_str}")).copied().unwrap_or(0.0);
let pres = cell.get(&format!("PRES:{lvl_str}")).copied().unwrap_or(0.0);
let spfh = cell.get(spfh_key.as_str()).copied().unwrap_or(0.0);
let pres = cell.get(pres_key.as_str()).copied().unwrap_or(0.0);
levels.push((hgt as f64, tmp as f64, spfh as f64, pres as f64));
}
@ -221,14 +228,14 @@ mod tests {
fn cell_with_levels(count: usize) -> CellValues {
let mut c = CellValues::new();
for i in 1..=count {
c.insert(format!("HGT:{i} hybrid level"), (i as f32) * 50.0);
c.insert(format!("TMP:{i} hybrid level"), 290.0 - (i as f32) * 0.5);
c.insert(format!("HGT:{i} hybrid level").into(), (i as f32) * 50.0);
c.insert(format!("TMP:{i} hybrid level").into(), 290.0 - (i as f32) * 0.5);
c.insert(
format!("SPFH:{i} hybrid level"),
format!("SPFH:{i} hybrid level").into(),
0.008 - (i as f32) * 0.0001,
);
c.insert(
format!("PRES:{i} hybrid level"),
format!("PRES:{i} hybrid level").into(),
101_000.0 - (i as f32) * 600.0,
);
}