use super::{GpuBackend, GpuProcess, GpuSnapshot};
use anyhow::{Context, Result};
use std::io::BufRead;
use std::path::Path;
const MAX_LINE_BYTES: usize = 8 * 1024 * 1024;
#[derive(serde::Deserialize)]
struct LogRecord {
#[serde(default)]
backend: Option<String>,
#[serde(default)]
driver: Option<String>,
#[serde(default)]
gpus: Vec<GpuSnapshot>,
#[serde(default)]
processes: Vec<GpuProcess>,
}
struct BoundedLines<R: BufRead> {
inner: R,
cap: usize,
}
impl<R: BufRead> BoundedLines<R> {
fn new(inner: R, cap: usize) -> Self {
BoundedLines { inner, cap }
}
fn drain_to_newline(&mut self) {
loop {
let chunk = match self.inner.fill_buf() {
Ok([]) => return,
Ok(c) => c,
Err(_) => return,
};
match chunk.iter().position(|&b| b == b'\n') {
Some(p) => {
self.inner.consume(p + 1);
return;
}
None => {
let len = chunk.len();
self.inner.consume(len);
}
}
}
}
}
impl<R: BufRead> Iterator for BoundedLines<R> {
type Item = std::io::Result<String>;
fn next(&mut self) -> Option<Self::Item> {
let mut buf: Vec<u8> = Vec::new();
loop {
let chunk = match self.inner.fill_buf() {
Ok([]) => {
return if buf.is_empty() {
None
} else {
Some(Ok(String::from_utf8_lossy(&buf).into_owned()))
};
}
Ok(c) => c,
Err(e) => return Some(Err(e)),
};
if let Some(pos) = chunk.iter().position(|&b| b == b'\n') {
if buf.len() + pos <= self.cap {
buf.extend_from_slice(&chunk[..pos]);
self.inner.consume(pos + 1);
return Some(Ok(String::from_utf8_lossy(&buf).into_owned()));
}
self.inner.consume(pos + 1);
buf.clear();
continue;
}
buf.extend_from_slice(chunk);
let len = chunk.len();
self.inner.consume(len);
if buf.len() > self.cap {
self.drain_to_newline();
buf.clear();
}
}
}
}
pub struct ReplayBackend {
lines: BoundedLines<std::io::BufReader<std::fs::File>>,
last: LogRecord,
finished: bool,
first: bool,
}
pub fn load(path: &Path) -> Result<Box<dyn GpuBackend>> {
let file = std::fs::File::open(path)
.with_context(|| format!("opening replay log {}", path.display()))?;
let mut lines = BoundedLines::new(std::io::BufReader::new(file), MAX_LINE_BYTES);
let first = next_record(&mut lines)
.with_context(|| format!("{}: no valid JSONL records", path.display()))?;
Ok(Box::new(ReplayBackend {
lines,
last: first,
finished: false,
first: true,
}))
}
fn next_record<R: BufRead>(lines: &mut BoundedLines<R>) -> Option<LogRecord> {
for line in lines.by_ref() {
let Ok(line) = line else { return None };
if let Ok(rec) = serde_json::from_str::<LogRecord>(&line) {
return Some(rec);
}
}
None
}
impl GpuBackend for ReplayBackend {
fn name(&self) -> &'static str {
"replay"
}
fn poll(&mut self) -> Result<Vec<GpuSnapshot>> {
if self.first {
self.first = false;
} else if !self.finished {
match next_record(&mut self.lines) {
Some(rec) => self.last = rec,
None => self.finished = true, }
}
let mut gpus = self.last.gpus.clone();
for (i, g) in gpus.iter_mut().enumerate() {
if g.device_id.is_none() {
g.device_id = Some(format!("replay:{i}"));
}
}
Ok(gpus)
}
fn processes(&mut self) -> Vec<GpuProcess> {
let frame = self.last.gpus.len();
self.last
.processes
.iter()
.filter(|p| p.gpu_index < frame)
.cloned()
.collect()
}
fn driver_info(&self) -> Option<String> {
match (&self.last.backend, &self.last.driver) {
(Some(b), Some(d)) => Some(format!("{b} · {d}")),
(Some(b), None) => Some(b.clone()),
(None, d) => d.clone(),
}
}
fn can_signal(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write_log(name: &str, body: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("gpur-replay-{name}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("rec.jsonl");
std::fs::write(&path, body).unwrap();
path
}
#[test]
fn recorded_attribution_survives_playback() {
let path = write_log(
"attr",
concat!(
r#"{"ts_ms":1,"backend":"nvml","driver":"550.1","gpus":[{"name":"card0"}],"#,
r#""processes":[{"pid":7,"container":"docker:abcdef123456"}]}"#,
"\n"
),
);
let mut b = load(&path).unwrap();
assert_eq!(b.driver_info().as_deref(), Some("nvml · 550.1"));
assert_eq!(
b.processes()[0].container.as_deref(),
Some("docker:abcdef123456")
);
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn rows_naming_a_card_outside_the_recorded_frame_are_dropped() {
let path = write_log(
"stray",
concat!(
r#"{"gpus":[{"name":"card0"},{"name":"card1"}],"processes":["#,
r#"{"pid":1,"gpu_index":0},{"pid":2,"gpu_index":1},"#,
r#"{"pid":3,"gpu_index":7}]}"#,
"\n"
),
);
let mut b = load(&path).unwrap();
assert_eq!(b.poll().unwrap().len(), 2);
let pids: Vec<u32> = b.processes().iter().map(|p| p.pid).collect();
assert_eq!(pids, vec![1, 2]);
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn a_pre_option_recording_keeps_its_recorded_memory() {
let path = write_log(
"mem",
concat!(
r#"{"gpus":[{"name":"card0"}],"processes":["#,
r#"{"pid":1,"gpu_mem_bytes":0},"#,
r#"{"pid":2,"gpu_mem_bytes":2147483648}]}"#,
"\n"
),
);
let mut b = load(&path).unwrap();
let procs = b.processes();
assert_eq!(procs[0].gpu_mem_bytes, Some(0));
assert_eq!(procs[1].gpu_mem_bytes, Some(2_147_483_648));
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn a_record_omitting_memory_replays_as_unknown() {
let path = write_log(
"nomem",
concat!(
r#"{"gpus":[{"name":"card0"}],"processes":[{"pid":1}]}"#,
"\n"
),
);
let mut b = load(&path).unwrap();
assert_eq!(b.processes()[0].gpu_mem_bytes, None);
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn the_first_poll_returns_the_preloaded_record() {
let path = write_log(
"first",
concat!(
r#"{"gpus":[{"name":"first"}],"processes":[]}"#,
"\n",
r#"{"gpus":[{"name":"second"}],"processes":[]}"#,
"\n"
),
);
let mut b = load(&path).unwrap();
assert_eq!(b.poll().unwrap()[0].name, "first");
assert_eq!(b.poll().unwrap()[0].name, "second");
assert_eq!(b.poll().unwrap()[0].name, "second");
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn legacy_records_without_attribution_still_load() {
let path = write_log("legacy", "{\"gpus\":[],\"processes\":[]}\n");
let b = load(&path).unwrap();
assert_eq!(b.driver_info(), None);
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
#[test]
fn bounded_lines_drops_oversized_lines_and_keeps_the_rest() {
let data = "123456789\n12345678\nabc\n123456789012345\nno-eol";
let lines = BoundedLines::new(
std::io::BufReader::with_capacity(4, std::io::Cursor::new(data)),
8,
);
let got: Vec<String> = lines.map(|l| l.unwrap()).collect();
assert_eq!(got, vec!["12345678", "abc", "no-eol"]);
}
#[test]
fn a_record_longer_than_the_line_cap_is_dropped() {
let giant = format!(
r#"{{"gpus":[{{"name":"{}"}}],"processes":[]}}"#,
"x".repeat(MAX_LINE_BYTES + 1)
);
let path = write_log(
"giant",
&format!("{giant}\n{{\"gpus\":[{{\"name\":\"ok\"}}],\"processes\":[]}}\n"),
);
let mut b = load(&path).unwrap();
assert_eq!(b.poll().unwrap()[0].name, "ok");
std::fs::remove_dir_all(path.parent().unwrap()).ok();
}
}