Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions crates/prism-lium-types/src/json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,25 @@ pub fn parse_one_offer(item: &Value) -> Option<Offer> {
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<u32> {
v.as_u64()
.map(|n| n as u32)
.or_else(|| v.as_f64().and_then(finite_u32))
}

fn finite_u32(f: f64) -> Option<u32> {
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 {
Expand Down
155 changes: 149 additions & 6 deletions crates/prism-lium-types/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>,
/// Free GPUs on the host (`available_gpu_count`).
#[serde(default)]
pub available_gpu_count: Option<u32>,
}

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).
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand All @@ -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);
Expand Down Expand Up @@ -618,6 +740,7 @@ mod tests {
gpu_count,
price_per_hour: price,
provider: "lium".into(),
..Offer::default()
};
let two_6000 = mk(
"2x6000",
Expand Down Expand Up @@ -655,20 +778,23 @@ mod tests {
gpu_count: 1,
price_per_hour: 2.0,
provider: "lium".into(),
..Offer::default()
};
let eight = Offer {
id: "8x".into(),
gpu_type: "NVIDIA GeForce RTX 5090".into(),
gpu_count: 8,
price_per_hour: 0.48,
provider: "lium".into(),
..Offer::default()
};
let eight_label = Offer {
id: "8x-label".into(),
gpu_type: "8x RTX 5090".into(),
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));
Expand Down Expand Up @@ -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
Expand Down
31 changes: 25 additions & 6 deletions crates/prism-lium/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions crates/prism-lium/src/sim.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions crates/prism-verda/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,7 @@ impl EvalJobBackend for VerdaClient {
gpu_count: 1,
price_per_hour: 0.0,
provider: "verda".into(),
..Offer::default()
})
})
.collect())
Expand Down
13 changes: 9 additions & 4 deletions docs/PRISM.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading