diff --git a/crates/prism-lium-types/src/json.rs b/crates/prism-lium-types/src/json.rs index 7fa5fd487..da68655e4 100644 --- a/crates/prism-lium-types/src/json.rs +++ b/crates/prism-lium-types/src/json.rs @@ -50,9 +50,25 @@ pub fn parse_one_offer(item: &Value) -> Option { gpu_count, price_per_hour: price, provider: "lium".into(), + min_gpu_count_for_rental: item.get("min_gpu_count_for_rental").and_then(as_u32), + available_gpu_count: item.get("available_gpu_count").and_then(as_u32), }) } +fn as_u32(v: &Value) -> Option { + v.as_u64() + .map(|n| n as u32) + .or_else(|| v.as_f64().and_then(finite_u32)) +} + +fn finite_u32(f: f64) -> Option { + if f.is_finite() && f >= 0.0 && f <= f64::from(u32::MAX) { + Some(f as u32) + } else { + None + } +} + /// Parse a `/pods/{id}` object. #[must_use] pub fn parse_instance(v: &Value, fallback_id: &str) -> Instance { diff --git a/crates/prism-lium-types/src/types.rs b/crates/prism-lium-types/src/types.rs index dfdf97a48..34f9aa023 100644 --- a/crates/prism-lium-types/src/types.rs +++ b/crates/prism-lium-types/src/types.rs @@ -17,6 +17,26 @@ pub struct Offer { pub price_per_hour: f64, /// Provider label. pub provider: String, + /// Lium GPU-split floor (`min_gpu_count_for_rental`). `None` = omitted. + #[serde(default)] + pub min_gpu_count_for_rental: Option, + /// Free GPUs on the host (`available_gpu_count`). + #[serde(default)] + pub available_gpu_count: Option, +} + +impl Default for Offer { + fn default() -> Self { + Self { + id: String::new(), + gpu_type: String::new(), + gpu_count: 1, + price_per_hour: f64::MAX, + provider: "lium".into(), + min_gpu_count_for_rental: None, + available_gpu_count: None, + } + } } /// Plausible discrete GPU counts on marketplace offers (not SKU numbers). @@ -122,15 +142,50 @@ impl Offer { effective_gpu_count(self.gpu_count, &self.gpu_type) > 1 } + /// Lium split: `min ≤ wanted ≤ available`. + /// + /// Idle 8× B200 rows often omit `min_gpu_count_for_rental` while still + /// listing `available_gpu_count` and `price_per_gpu`. Treat that as + /// min=1 so a 1× pin can rent one card — never the whole 8-pack. + /// Missing `available_gpu_count` means "do not infer split". + #[must_use] + pub fn allows_split_for(&self, wanted: u32) -> bool { + if wanted == 0 { + return false; + } + let Some(avail) = self.available_gpu_count else { + return false; + }; + if avail < wanted { + return false; + } + self.min_gpu_count_for_rental.unwrap_or(1) <= wanted + } + + /// GPUs to send on `POST /executors/{id}/rent`. + #[must_use] + pub fn rent_count(&self, requested: u32) -> u32 { + if self.allows_split_for(requested) { + return requested.max(1); + } + let effective = effective_gpu_count(self.gpu_count, &self.gpu_type); + if requested <= 1 { + 1 + } else { + effective.max(requested) + } + } + /// Whether this offer may be rented for `requested` GPUs. /// - /// A 1-GPU request still hard-rejects multi-GPU hosts (live SKU pin). - /// A multi-GPU request accepts a larger host (`effective >= requested`) - /// when no exact-width offer is listed. Lium rejects GPU splitting, so the - /// client rents `gpu_count = effective` (the whole host); the miner caps - /// DDP at the requested width. 8×5090 is never a silent fallback. + /// Accepts an exact-width host, a larger host when `requested > 1`, or a + /// multi-GPU host that advertises (or omits-min) GPU splitting. A 1-GPU + /// pin never takes a non-split 8× pack. 8×5090 is never a silent fallback. #[must_use] pub fn matches_gpu_count(&self, requested: u32) -> bool { + if self.allows_split_for(requested) { + return true; + } let effective = effective_gpu_count(self.gpu_count, &self.gpu_type); if requested <= 1 { return effective == 1; @@ -547,6 +602,7 @@ mod tests { gpu_count, price_per_hour: price, provider: "lium".into(), + ..Offer::default() }; let pin = mk("1xb200", "NVIDIA B200", 1, 5.5); let eight = mk("8xb200", "NVIDIA B200", 8, 5.6); @@ -561,13 +617,78 @@ mod tests { let mut offers = vec![eight.clone(), rtx.clone(), pro.clone(), pin.clone()]; pref.filter_sort_offers(&mut offers, 1); let ids: Vec<&str> = offers.iter().map(|o| o.id.as_str()).collect(); - assert_eq!(ids, ["1xb200"], "exact 1× B200 only; never 5090 or 8× B200"); + assert_eq!( + ids, + ["1xb200"], + "exact 1× B200 only; never 5090 or unlabeled 8× B200" + ); assert!(pin.matches_gpu_count(1)); assert!(!eight.matches_gpu_count(1)); assert!(!pref.matches_pin(&rtx.gpu_type)); assert!(!pref.matches_pin(&pro.gpu_type)); } + #[test] + fn one_gpu_b200_accepts_lium_split_and_idle_8x() { + let native = Offer { + id: "1xb200".into(), + gpu_type: "NVIDIA B200".into(), + gpu_count: 1, + price_per_hour: 5.5, + ..Offer::default() + }; + let split8 = Offer { + id: "8xb200-split".into(), + gpu_type: "NVIDIA B200".into(), + gpu_count: 8, + price_per_hour: 5.6, + min_gpu_count_for_rental: Some(1), + available_gpu_count: Some(4), + ..Offer::default() + }; + let idle8 = Offer { + id: "8xb200-idle".into(), + gpu_type: "NVIDIA B200".into(), + gpu_count: 8, + price_per_hour: 5.85, + available_gpu_count: Some(8), + ..Offer::default() + }; + let whole8 = Offer { + id: "8xb200-whole".into(), + gpu_type: "NVIDIA B200".into(), + gpu_count: 8, + price_per_hour: 5.0, + ..Offer::default() + }; + let min4 = Offer { + id: "8xb200-min4".into(), + gpu_type: "NVIDIA B200".into(), + gpu_count: 8, + price_per_hour: 5.1, + min_gpu_count_for_rental: Some(4), + available_gpu_count: Some(8), + ..Offer::default() + }; + assert!(split8.allows_split_for(1)); + assert!(idle8.allows_split_for(1)); + assert_eq!(split8.rent_count(1), 1); + assert_eq!(idle8.rent_count(1), 1); + assert!(split8.matches_gpu_count(1)); + assert!(idle8.matches_gpu_count(1)); + assert!(!whole8.matches_gpu_count(1)); + assert!(!min4.matches_gpu_count(1)); + let pref = GpuPreference::profile_b200(); + let mut offers = vec![whole8, min4, idle8.clone(), split8.clone(), native.clone()]; + pref.filter_sort_offers(&mut offers, 1); + let ids: Vec<&str> = offers.iter().map(|o| o.id.as_str()).collect(); + assert_eq!( + ids, + ["1xb200", "8xb200-split", "8xb200-idle"], + "native 1× first, then split/idle 8× rented as 1" + ); + } + #[test] fn four_gpu_request_matches_only_four_gpu_offers() { let mk = |id: &str, gpu_type: &str, gpu_count: u32, price: f64| Offer { @@ -576,6 +697,7 @@ mod tests { gpu_count, price_per_hour: price, provider: "lium".into(), + ..Offer::default() }; let one = mk("1x", "NVIDIA GeForce RTX 5090", 1, 2.0); let four = mk("4x", "NVIDIA GeForce RTX 5090", 4, 1.0); @@ -618,6 +740,7 @@ mod tests { gpu_count, price_per_hour: price, provider: "lium".into(), + ..Offer::default() }; let two_6000 = mk( "2x6000", @@ -655,6 +778,7 @@ mod tests { gpu_count: 1, price_per_hour: 2.0, provider: "lium".into(), + ..Offer::default() }; let eight = Offer { id: "8x".into(), @@ -662,6 +786,7 @@ mod tests { gpu_count: 8, price_per_hour: 0.48, provider: "lium".into(), + ..Offer::default() }; let eight_label = Offer { id: "8x-label".into(), @@ -669,6 +794,7 @@ mod tests { gpu_count: 1, // lying field; label wins price_per_hour: 0.48, provider: "lium".into(), + ..Offer::default() }; assert!(single.matches_gpu_count(1)); assert!(!eight.matches_gpu_count(1)); @@ -741,6 +867,23 @@ mod tests { assert!(v.telemetry.is_some()); } + #[test] + fn parse_live_idle_8x_b200_is_one_gpu_rent() { + let v = serde_json::json!({ + "id": "cb5e952f-bcb4-46ff-b7ae-16fc0118b30a", + "machine_name": "NVIDIA B200", + "gpu_count": 8, + "available_gpu_count": 8, + "min_gpu_count_for_rental": null, + "price_per_gpu": 5.85 + }); + let o = crate::parse_one_offer(&v).expect("offer"); + assert_eq!(o.gpu_type, "NVIDIA B200"); + assert!((o.price_per_hour - 5.85).abs() < 1e-9); + assert!(o.matches_gpu_count(1)); + assert_eq!(o.rent_count(1), 1); + } + #[test] fn remote_exec_result_v2_fields_default_absent() { // Mixed-version payloads (e.g. metrics_version set, other v2 keys diff --git a/crates/prism-lium/src/client.rs b/crates/prism-lium/src/client.rs index e2cb71528..8e355280e 100644 --- a/crates/prism-lium/src/client.rs +++ b/crates/prism-lium/src/client.rs @@ -789,12 +789,9 @@ impl EvalJobBackend for LiumClient { } let effective = prism_lium_types::effective_gpu_count(selected.gpu_count, &selected.gpu_type); - // 1-GPU rents 1; else whole host (no split). Never 8×5090 fallback. - let rent_gpu_count = if spec.gpu_count <= 1 { - 1 - } else { - effective.max(spec.gpu_count) - }; + // Split hosts: requested width (1× B200 on an 8× node). + // Non-split: 1-GPU stays 1; else whole host. Never 8×5090 fallback. + let rent_gpu_count = selected.rent_count(spec.gpu_count); if pref.matches_pin("RTX 5090") && rent_gpu_count >= 8 && spec.gpu_count < 8 { return Err(LiumError::Api(format!( "abort: refusing {rent_gpu_count}× 5090 rent (no 8×5090 fallback)" @@ -1296,6 +1293,28 @@ mod tests { assert_eq!(inst.id, "pod-1x"); } + #[tokio::test] + async fn provision_rents_one_gpu_on_idle_8x_b200() { + let server = MockServer::start().await; + mount_common( + &server, + serde_json::json!([ + { + "id": "eight-b200-idle", + "machine_name": "NVIDIA B200", + "gpu_count": 8, + "available_gpu_count": 8, + "price_per_gpu": 5.85 + } + ]), + ) + .await; + mount_rent_path(&server, "eight-b200-idle", "pod-split-1").await; + let c = LiumClient::with_base_url("test-key", server.uri()).unwrap(); + let inst = c.provision(&provision_spec()).await.unwrap(); + assert_eq!(inst.id, "pod-split-1"); + } + #[tokio::test] async fn provision_rejects_all_multi_gpu_offers() { let server = MockServer::start().await; diff --git a/crates/prism-lium/src/sim.rs b/crates/prism-lium/src/sim.rs index b93020a13..d90450d6f 100644 --- a/crates/prism-lium/src/sim.rs +++ b/crates/prism-lium/src/sim.rs @@ -52,6 +52,7 @@ impl EvalJobBackend for SimLiumBackend { gpu_count: 1, price_per_hour: price, provider: "sim".into(), + ..Offer::default() }; let mut offers = vec![ mk("sim-blackwell", "NVIDIA RTX BLACKWELL B200", 1.2), diff --git a/crates/prism-verda/src/lib.rs b/crates/prism-verda/src/lib.rs index 85a1adad0..59a4d6718 100644 --- a/crates/prism-verda/src/lib.rs +++ b/crates/prism-verda/src/lib.rs @@ -487,6 +487,7 @@ impl EvalJobBackend for VerdaClient { gpu_count: 1, price_per_hour: 0.0, provider: "verda".into(), + ..Offer::default() }) }) .collect()) diff --git a/docs/PRISM.md b/docs/PRISM.md index 4ae86b0a2..5c6148e57 100644 --- a/docs/PRISM.md +++ b/docs/PRISM.md @@ -954,16 +954,21 @@ overridable Docker `CMD` so the provider bootstrap can run. **GPU profiles + netns contract.** Lium profiles, never mixed in one job: 1. **Default / 1B dense:** `PRISM_POD_GPU_COUNT=1` + name match **NVIDIA B200** - (needles `B200`, `NVIDIA B200`). Exact 1× only — **do not** fall through to - 8× B200, 5090, or RTX PRO 6000. ~180–192 GB → dense-1b uses mb≥8, + (needles `B200`, `NVIDIA B200`). Rent **one** GPU: a native 1× offer, or an + 8× B200 host that advertises Lium GPU splitting (`min_gpu_count_for_rental` + ≤ 1 ≤ `available_gpu_count`, or omitted `min` with `available ≥ 1` on idle + hosts). Do **not** buy the whole 8-pack, and do **not** fall through to + 5090 or RTX PRO 6000. ~180–192 GB → dense-1b uses mb≥8, `DENSE1B_TE=1` default, checkpoint off, DDP world=1. 2. **Explicit env fallbacks:** `PRISM_POD_GPU_COUNT=4` + **RTX 5090** (exact 4×; **do not** fall through to 8×5090). `PRISM_POD_GPU_COUNT=2` or `8` + **RTX PRO 6000 Blackwell** (Server Edition). ~96 GB/card → mb≥4, TE on, checkpoint off. -Lium inventory snapshot (2026-08-19): **1× NVIDIA B200** listed at -**$5.50/gpu-hr** (two offers). 8× B200 @ $5.60/gpu-hr is **not** the pin. +Lium inventory snapshot (2026-08-22): marketplace lists **8× NVIDIA B200** +hosts at **$5.60–$6.53/gpu-hr**. Native 1× B200 is often empty; the pin +rents `gpu_count=1` on those 8× rows. A non-split 8× pack (no +`available_gpu_count`) is **not** the pin. RTX PRO 6000 Server Edition remains an env fallback (1× @ $1.29; 8× @ $1.01–$1.85). Ada “RTX 6000” is **not** the 6000 pin. Do **not** treat a 5090 as a B200.