Skip to content
Draft
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions crates/hip-bridge/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,7 @@ required-features = ["lab"]
[[example]]
name = "vmm_arena_smoke"
required-features = ["lab"]

[[example]]
name = "pinned_smoke"
required-features = ["lab"]
59 changes: 59 additions & 0 deletions crates/hip-bridge/examples/pinned_smoke.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Nick Woolmer
// hipfire — see LICENSE and NOTICE in the project root.

//! Smoke test for page-locked host staging (`hipHostMalloc`/`hipHostFree`).
//!
//! An FFI signature mistake compiles cleanly and only shows up as a runtime
//! crash or silent corruption, so this asserts a full round-trip through a
//! pinned buffer rather than merely checking that the symbols resolved.
//!
//! Pinned staging is the prerequisite for genuinely async H2D on a DISCRETE
//! GPU: a pageable source is staged through a driver bounce buffer, which both
//! halves bandwidth and serialises against compute. On an APU (gfx1151) the GPU
//! allocates from system RAM, so this path is expected to work but buy nothing
//! — correctness here, benefit only on a dGPU.

use std::ffi::c_void;

fn main() {
let hip = hip_bridge::HipRuntime::load().expect("failed to load HIP runtime");
hip.set_device(0).expect("failed to set device");

if !hip.has_pinned_host_alloc() {
println!(
"pinned_smoke: hipHostMalloc/hipHostFree NOT available — \
transport will fall back to pageable staging"
);
println!("pinned_smoke: SKIP (not a failure)");
return;
}
println!("pinned_smoke: pinned host alloc available");

const N: usize = 8 << 20; // 8 MiB — larger than one expert role-blob (~3.4 MB)

let host = unsafe { hip.host_malloc(N) }.expect("hipHostMalloc failed");
println!("pinned_smoke: allocated {N} B of pinned host memory at {host:?}");

// Fill the pinned buffer with a non-trivial pattern.
let src: Vec<u8> = (0..N).map(|i| (i.wrapping_mul(31) % 251) as u8).collect();
unsafe { std::ptr::copy_nonoverlapping(src.as_ptr(), host as *mut u8, N) };

// Round-trip: pinned host -> device -> pageable host.
let dev = hip.malloc(N).expect("hipMalloc failed");
let pinned_slice = unsafe { std::slice::from_raw_parts(host as *const u8, N) };
hip.memcpy_htod(&dev, pinned_slice)
.expect("H2D from pinned failed");

let mut back = vec![0u8; N];
hip.memcpy_dtoh(&mut back, &dev).expect("D2H failed");

assert_eq!(src, back, "pinned round-trip corrupted data");
println!("pinned_smoke: {N} B round-trip VERIFIED byte-identical");

hip.free(dev).expect("hipFree failed");
unsafe { hip.host_free(host as *mut c_void) }.expect("hipHostFree failed");
println!("pinned_smoke: freed cleanly");

println!("\npinned_smoke: PASS");
}
6 changes: 3 additions & 3 deletions crates/hip-bridge/map.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside
| File | Lines | Public items | Tests |
|---|---:|---:|---:|
| [`src/error.rs`](src/error.rs) | 70 | 8 | 0 |
| [`src/ffi.rs`](src/ffi.rs) | 1,772 | 96 | 0 |
| [`src/ffi.rs`](src/ffi.rs) | 1,881 | 100 | 0 |
| [`src/kernarg.rs`](src/kernarg.rs) | 178 | 14 | 3 |
| [`src/lib.rs`](src/lib.rs) | 176 | 17 | 2 |
| [`src/rccl.rs`](src/rccl.rs) | 470 | 18 | 0 |
Expand All @@ -34,7 +34,7 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside
### Public API surface

- [`src/error.rs`](src/error.rs): `HipErrorCode`, `HipResult`, `HIP_ERROR_INVALID_IMAGE`, `HIP_ERROR_PEER_ACCESS_UNSUPPORTED`, `HIP_ERROR_PEER_ACCESS_ALREADY_ENABLED`, `HIP_ERROR_PEER_ACCESS_NOT_ENABLED`, `HipError`, `new`
- [`src/ffi.rs`](src/ffi.rs): `launch_counters`, `record`, `record_bytes`, `time_ns`, `count`, `bytes`, `reset`, `HipMemGenericAllocationHandle`, `HIP_MEM_LOCATION_TYPE_DEVICE`, `HIP_MEM_ALLOCATION_TYPE_PINNED`, `HIP_MEM_ACCESS_FLAGS_PROT_READ_WRITE`, `HIP_MEM_ALLOCATION_GRANULARITY_MINIMUM`, +84 more
- [`src/ffi.rs`](src/ffi.rs): `launch_counters`, `record`, `record_bytes`, `time_ns`, `count`, `bytes`, `reset`, `HipMemGenericAllocationHandle`, `HIP_MEM_LOCATION_TYPE_DEVICE`, `HIP_MEM_ALLOCATION_TYPE_PINNED`, `HIP_MEM_ACCESS_FLAGS_PROT_READ_WRITE`, `HIP_MEM_ALLOCATION_GRANULARITY_MINIMUM`, +88 more
- [`src/kernarg.rs`](src/kernarg.rs): `KernargBlob`, `new`, `with_capacity`, `len`, `is_empty`, `push_ptr`, `push_u32`, `push_i32`, `push_f32`, `push_u64`, `pad_to`, `as_mut_slice`, +2 more
- [`src/lib.rs`](src/lib.rs): `error`, `ffi`, `kernarg`, `rccl`, `rocblas`, `vmm`, `MemcpyKind`, `MemoryType`, `from_raw`, `DeviceBuffer`, `as_ptr`, `size`, +5 more
- [`src/rccl.rs`](src/rccl.rs): `NCCL_SUCCESS`, `RcclError`, `RcclResult`, `RcclDataType`, `RcclRedOp`, `RcclComms`, `init_all`, `len`, `is_empty`, `version`, `group_start`, `group_end`, +6 more
Expand All @@ -54,6 +54,6 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside

### Totals

- 7 modules · 3,853 lines · 179 public items · 12 tests · 8 examples
- 7 modules · 3,962 lines · 183 public items · 12 tests · 9 examples

<!-- crate-map:generated:end -->
109 changes: 109 additions & 0 deletions crates/hip-bridge/src/ffi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,16 @@ pub struct HipRuntime {
unsafe extern "C" fn(*mut c_void, *const c_void, usize, c_uint, HipStream) -> u32,
fn_memset: unsafe extern "C" fn(*mut c_void, c_int, usize) -> u32,
fn_memset_async: unsafe extern "C" fn(*mut c_void, c_int, usize, HipStream) -> u32,
// Pinned (page-locked) host memory. Optional so a runtime without them
// degrades to the pageable staging path rather than failing to init.
//
// Only worth using on a DISCRETE GPU: a pageable H2D copy is staged through
// a driver bounce buffer (roughly half bandwidth) and cannot overlap with
// compute, whereas a pinned copy DMAs straight off the host buffer. On an
// APU (gfx1151 and friends) the GPU allocates out of system RAM, so there
// is no bus hop to optimise and pinning buys nothing.
fn_host_malloc: Option<unsafe extern "C" fn(*mut *mut c_void, usize, c_uint) -> u32>,
fn_host_free: Option<unsafe extern "C" fn(*mut c_void) -> u32>,
fn_mem_address_reserve:
Option<unsafe extern "C" fn(*mut *mut c_void, usize, usize, *mut c_void, u64) -> u32>,
fn_mem_address_free: Option<unsafe extern "C" fn(*mut c_void, usize) -> u32>,
Expand Down Expand Up @@ -493,6 +503,16 @@ impl HipRuntime {
"hipMemsetAsync",
unsafe extern "C" fn(*mut c_void, c_int, usize, HipStream) -> u32
),
fn_host_malloc: load_optional_fn!(
lib,
"hipHostMalloc",
unsafe extern "C" fn(*mut *mut c_void, usize, c_uint) -> u32
),
fn_host_free: load_optional_fn!(
lib,
"hipHostFree",
unsafe extern "C" fn(*mut c_void) -> u32
),
fn_mem_address_reserve: load_optional_fn!(
lib,
"hipMemAddressReserve",
Expand Down Expand Up @@ -919,6 +939,54 @@ impl HipRuntime {
self.check(code, "hipFree")
}

/// Is page-locked host allocation available on this runtime?
///
/// Callers use this to pick a staging strategy at construction time rather
/// than discovering the gap on the first copy.
pub fn has_pinned_host_alloc(&self) -> bool {
self.fn_host_malloc.is_some() && self.fn_host_free.is_some()
}

/// Allocate `size` bytes of page-locked host memory.
///
/// Pinned memory is what makes `hipMemcpyAsync` genuinely asynchronous: a
/// pageable source forces the driver to stage through an internal bounce
/// buffer, which both halves effective bandwidth and serialises the copy
/// against compute. Returns the raw host pointer; pair with
/// [`Self::host_free`].
///
/// Pinned pages are unswappable, so callers are responsible for keeping the
/// total bounded — on a swapless box an unbounded pinned pool is an OOM.
///
/// # Safety
/// The returned pointer is uninitialised. Caller owns it until `host_free`.
pub unsafe fn host_malloc(&self, size: usize) -> HipResult<*mut c_void> {
let f = self
.fn_host_malloc
.ok_or_else(|| HipError::new(0, "hipHostMalloc unavailable on this runtime"))?;
let mut ptr: *mut c_void = ptr::null_mut();
// flags=0 → hipHostMallocDefault (page-locked, mapped, not write-combined).
let code = unsafe { f(&mut ptr, size, 0) };
self.check(code, "hipHostMalloc")?;
if ptr.is_null() {
return Err(HipError::new(0, "hipHostMalloc returned a null pointer"));
}
Ok(ptr)
}

/// Release a pointer obtained from [`Self::host_malloc`].
///
/// # Safety
/// `ptr` must have come from `host_malloc` and must not be referenced by
/// any in-flight async copy.
pub unsafe fn host_free(&self, p: *mut c_void) -> HipResult<()> {
let f = self
.fn_host_free
.ok_or_else(|| HipError::new(0, "hipHostFree unavailable on this runtime"))?;
let code = unsafe { f(p) };
self.check(code, "hipHostFree")
}

pub fn mem_get_allocation_granularity(
&self,
prop: &HipMemAllocationProp,
Expand Down Expand Up @@ -1063,6 +1131,47 @@ impl HipRuntime {
self.check(code, "hipMemcpy H2D offset")
}

/// Offset H2D copy issued on `stream` instead of the null stream.
///
/// This is the piece the expert pager needs to overlap a fetch with
/// compute: the pager writes into a slot at `offset` inside a pooled blob,
/// and it must be able to do so without blocking the caller.
///
/// `src` MUST be page-locked (see [`Self::host_malloc`]). `hipMemcpyAsync`
/// from pageable memory silently degrades to a synchronous copy through a
/// driver bounce buffer, which would make this look like it works while
/// delivering none of the overlap.
///
/// # Safety contract
/// `src` must stay alive and unmodified until the copy completes — record
/// an event on `stream` and wait on it before reusing the buffer.
pub fn memcpy_htod_offset_async(
&self,
dst: &DeviceBuffer,
offset: usize,
src: &[u8],
stream: &Stream,
) -> HipResult<()> {
assert!(
offset + src.len() <= dst.size,
"offset ({}) + source ({}) exceeds device buffer ({})",
offset,
src.len(),
dst.size
);
let dst_ptr = unsafe { (dst.ptr as *mut u8).add(offset) as *mut c_void };
let code = unsafe {
(self.fn_memcpy_async)(
dst_ptr,
src.as_ptr() as *const c_void,
src.len(),
MemcpyKind::HostToDevice as c_uint,
stream.0,
)
};
self.check(code, "hipMemcpyAsync H2D offset")
}

/// Copy bytes between GPU buffers with offsets on both sides.
pub fn memcpy_dtod_at(
&self,
Expand Down
5 changes: 5 additions & 0 deletions crates/hipfire-arch-deepseek4/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ description = "DeepSeek V4 Flash architecture for hipfire (Hyper-Connections + c
# `deepseek4_chat` example binary.

[dependencies]
half.workspace = true
hipfire-config = { path = "../hipfire-config" }
hipfire-runtime = { path = "../hipfire-runtime" }
hipfire-ds4-parent = { path = "../hipfire-ds4-parent" }
Expand Down Expand Up @@ -114,3 +115,7 @@ required-features = ["lab"]
[[example]]
name = "ep_dspark_topology_probe"
required-features = ["lab"]

[[example]]
name = "expert_policy_sim"
required-features = ["lab"]
88 changes: 88 additions & 0 deletions crates/hipfire-arch-deepseek4/examples/expert_policy_sim.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Nick Woolmer
// hipfire — see LICENSE and NOTICE in the project root.

//! Replay a routed-expert access trace through candidate eviction policies.
//!
//! The P0 gate from `docs/specs/2026-07-19-weight-pager-eviction-policy.md`:
//! decide whether the pager's eviction policy is worth changing by measuring
//! the LRU-vs-Belady gap on real routing, offline. The spec's stop rule is
//! "if LRU is within ~2% of Belady, record that and stop".
//!
//! Capture a trace with `HIPFIRE_DEEPSEEK4_EXPERT_TRACE=<path>`, then:
//!
//! cargo run --release -p hipfire-arch-deepseek4 --example expert_policy_sim -- <path> [caps...]

use hipfire_arch_deepseek4::expert_policy::{parse_trace, simulate, Policy};

fn main() {
let mut args = std::env::args().skip(1);
let path = match args.next() {
Some(p) => p,
None => {
eprintln!("usage: expert_policy_sim <trace.csv> [slots...]");
std::process::exit(2);
}
};
let caps: Vec<usize> = {
let v: Vec<usize> = args.filter_map(|a| a.parse().ok()).collect();
if v.is_empty() {
vec![4, 8, 16, 25, 32, 64, 128]
} else {
v
}
};
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) => {
eprintln!("cannot read {path}: {e}");
std::process::exit(2);
}
};
let trace = parse_trace(&text);
let mut buckets: std::collections::BTreeSet<(u16, &str)> = Default::default();
let mut experts: std::collections::BTreeSet<u16> = Default::default();
for a in &trace {
buckets.insert((
a.layer,
match a.role {
hipfire_arch_deepseek4::expert_pager::ExpertBlobRole::GateUp => "g",
hipfire_arch_deepseek4::expert_pager::ExpertBlobRole::Down => "d",
},
));
experts.insert(a.expert);
}
println!(
"trace: {} accesses, {} buckets (layer x role), {} distinct experts",
trace.len(),
buckets.len(),
experts.len()
);
println!();
println!(
"{:>6} {:>9} {:>9} {:>9} {:>9} {:>12}",
"slots", "Belady", "LRU", "LFU", "LeastStale", "LRU vs Belady"
);
println!("{}", "-".repeat(70));
for &c in &caps {
let bel = simulate(&trace, c, Policy::Belady);
let lru = simulate(&trace, c, Policy::Lru);
let lfu = simulate(&trace, c, Policy::Lfu);
let ls = simulate(&trace, c, Policy::LeastStale);
// Gap in miss-rate percentage points, and as excess reads over optimal.
let gap_pp = (lru.miss_rate() - bel.miss_rate()) * 100.0;
let excess = lru.misses as f64 / bel.misses.max(1) as f64;
println!(
"{:>6} {:>8.1}% {:>8.1}% {:>8.1}% {:>8.1}% {:>+6.1} pp {:.2}x",
c,
bel.miss_rate() * 100.0,
lru.miss_rate() * 100.0,
lfu.miss_rate() * 100.0,
ls.miss_rate() * 100.0,
gap_pp,
excess
);
}
println!();
println!("Stop rule (spec P0): if LRU is within ~2 pp of Belady, record and stop.");
}
Loading
Loading