render: the M4 audit follow-ups — autocache priority, cancel-in-flight, decode LRU

- Autocache range jobs now post at Background priority
  (submit_video_background): they used to go through the Seek path and,
  after the M4 seek over-admission, jumped ahead of playback and past the
  render-queue bound. The interactive single-frame preview keeps Seek.
- Job.cancelled: the arena installs the slot's cancel atom, and
  execute_job finishes a cancelled job with Error::State before running
  the producer — a cancel no longer burns a full render/GPU pass only to
  discard the result. Exactly-once delivery is unchanged.
- DECODE_LRU_CAP 8 -> 2: the decode service's LRU is a hand-off buffer,
  not the cache of record (the eval-side decoded_frames LRU is); the
  double-cache footprint at 1080p F32 drops by ~6 frames. A hand-off miss
  is served from the eval cache without a new decode.
- Tests: sequence-aware preview cancel, over-admitted seek ordering,
  deterministic prefetch LRU reuse, cancelled-job skip, autocache
  priority. docs §3.4 backfilled with the A/B/C audit outcomes.
This commit is contained in:
2026-09-15 19:29:17 +08:00
parent ba1143e7a3
commit 7e87ec135e
9 changed files with 479 additions and 185 deletions
+46 -2
View File
@@ -139,8 +139,15 @@ impl PreviewAutoCacher {
}
/// Start a caching job for `range` of `owner`.
///
/// M4 priority fix: the range job is Background (§3.3 交互 > 播放 >
/// 导出 > 自动缓存). It used to go through `submit_video` (Seek), which
/// after the M4 seek over-admission let every autocache frame jump
/// ahead of playback *and* past the render-queue bound. The
/// interactive single-frame preview ([`PreviewAutoCacher::single_frame`])
/// keeps Seek.
fn start_range_job(&mut self, owner: u64, range: TimeRange) {
let id = self.arena.submit_video(
let id = self.arena.submit_video_background(
VideoTicketParams {
viewer: owner,
project: String::new(),
@@ -324,6 +331,44 @@ mod tests {
(PreviewAutoCacher::new(arena), d)
}
/// A dispatcher that records the posted schedules instead of running
/// them (the priority test only needs the posted `JobSchedule`).
#[derive(Default)]
struct RecordingDispatcher {
schedules: Mutex<Vec<crate::worker::JobSchedule>>,
}
impl JobDispatch for RecordingDispatcher {
fn post(&self, job: crate::worker::Job) -> bool {
lock(&self.schedules).push(job.schedule);
true
}
fn shutdown(&self) {}
}
#[test]
fn range_jobs_are_background_and_single_frames_stay_seek() {
let d = Arc::new(RecordingDispatcher::default());
let arena = Arc::new(TicketArena::new(d.clone(), frame_producer()));
let mut c = PreviewAutoCacher::new(arena);
c.attach(7).unwrap();
c.on_cache_request(7, TimeRange::new(Rational::new(0, 1), Rational::new(10, 1)));
c.single_frame(Rational::new(0, 1));
let schedules = lock(&d.schedules);
assert_eq!(schedules.len(), 2, "one range job + one single frame");
assert_eq!(
schedules[0].priority,
crate::scheduler::FramePriority::Background,
"autocache must yield to playback (§3.3)"
);
assert_eq!(
schedules[1].priority,
crate::scheduler::FramePriority::Seek,
"the interactive single-frame preview stays Seek"
);
}
#[test]
fn attach_detach_lifecycle() {
let (mut c, d) = new_cacher();
@@ -418,7 +463,6 @@ mod tests {
stop: AtomicU32,
}
#[test]
fn events_deliver_progress() {
let (mut c, d) = new_cacher();
+67 -25
View File
@@ -96,10 +96,15 @@ pub const RENDER_QUEUE_CAP: usize = 8;
/// path to the media, but still small enough to bound latency.
pub const DECODE_QUEUE_CAP: usize = 16;
/// Decoded-frame LRU capacity, in frames (~31 MB each at 1080p F32, so a
/// handful of frames is already hundreds of MB). Sized for a playback
/// window plus the montage baseline; the M2 decoder work lands here.
pub const DECODE_LRU_CAP: usize = 8;
/// Decoded-frame LRU capacity, in frames (~33 MB each at 1080p F32, so a
/// small hand-off buffer, not a cache of record). The eval-side
/// `decoded_frames` LRU (24 frames) is the cache of record; this one only
/// bridges the decode thread and the render request. Keeping it at the
/// read-ahead window bounds the double-cache overhead: at 1080p F32 the
/// service copy stays ~2 frames instead of 8 (~200 MB saved). A request
/// the hand-off missed is served from the eval cache without a decode —
/// only the deep copy is paid again.
pub const DECODE_LRU_CAP: usize = 2;
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
@@ -388,7 +393,10 @@ impl DecodeService {
return false;
}
if self.enqueue(DecodeCommand::Prefetch { request }, false) {
self.inner.counters.prefetches.fetch_add(1, Ordering::Relaxed);
self.inner
.counters
.prefetches
.fetch_add(1, Ordering::Relaxed);
true
} else {
self.inner
@@ -506,7 +514,14 @@ fn serve(
return Ok(texture);
}
let texture = decode(request, inner)?;
lru_insert(lru, request.clone(), texture.clone(), lru_capacity, inner, tick);
lru_insert(
lru,
request.clone(),
texture.clone(),
lru_capacity,
inner,
tick,
);
inner.lru_len.store(lru.len(), Ordering::Relaxed);
Ok(texture)
}
@@ -670,9 +685,8 @@ impl PipelineBackend {
// Prefetch is allowed only while the render queue has room: it must
// never fill the decode queue behind a saturated pipeline.
let gate_depth = depth.clone();
let gate: PrefetchGate = Arc::new(move || {
gate_depth.load(Ordering::Relaxed) < RENDER_QUEUE_CAP
});
let gate: PrefetchGate =
Arc::new(move || gate_depth.load(Ordering::Relaxed) < RENDER_QUEUE_CAP);
let decode = DecodeService::new(DECODE_LRU_CAP, gate);
let inner = Arc::new(PipelineInner {
queue: Mutex::new(VecDeque::new()),
@@ -687,7 +701,9 @@ impl PipelineBackend {
executed: AtomicU64::new(0),
drained: AtomicU64::new(0),
});
let backend = Arc::new(Self { inner: inner.clone() });
let backend = Arc::new(Self {
inner: inner.clone(),
});
let handle = std::thread::Builder::new()
.name("oak-render".into())
.spawn(move || render_loop(inner))
@@ -797,7 +813,9 @@ impl PipelineBackend {
inner.depth.store(0, Ordering::Relaxed);
jobs
};
inner.drained.fetch_add(jobs.len() as u64, Ordering::Relaxed);
inner
.drained
.fetch_add(jobs.len() as u64, Ordering::Relaxed);
inner.work.notify_all();
inner.room.notify_all();
for job in jobs {
@@ -921,8 +939,7 @@ mod tests {
"oakrender_pipeline_{tag}_{}.mp4",
std::process::id()
));
oak_codec::testmedia::write_test_clip(&path, 64, 64, 10, 10)
.expect("test clip generation");
oak_codec::testmedia::write_test_clip(&path, 64, 64, 10, 10).expect("test clip generation");
path
}
@@ -971,7 +988,9 @@ mod tests {
let off = y * stride + x * 16;
let mut out = [0f32; 4];
for i in 0..4 {
out[i] = f32::from_le_bytes(frame.data[off + i * 4..off + i * 4 + 4].try_into().unwrap());
out[i] = f32::from_le_bytes(
frame.data[off + i * 4..off + i * 4 + 4].try_into().unwrap(),
);
}
out
};
@@ -981,7 +1000,10 @@ mod tests {
assert!(r > 0.5 && g < 0.4 && b < 0.4, "{tag}: red half {r},{g},{b}");
assert!(a > 0.9, "{tag}: opaque {a}");
let [r, g, b, a] = read((48 - shift).rem_euclid(64) as usize, 32);
assert!(b > 0.5 && r < 0.4 && g < 0.4, "{tag}: blue half {r},{g},{b}");
assert!(
b > 0.5 && r < 0.4 && g < 0.4,
"{tag}: blue half {r},{g},{b}"
);
assert!(a > 0.9, "{tag}: opaque {a}");
}
@@ -1056,8 +1078,7 @@ mod tests {
let err = service
.request(req)
.expect("service available")
.err()
.expect("decoding a missing file must fail");
.expect_err("decoding a missing file must fail");
let _ = err.code(); // an explainable error, not a panic
let stats = service.stats();
assert_eq!(stats.errors, 1);
@@ -1108,9 +1129,10 @@ mod tests {
let path = test_clip("gate");
let open = Arc::new(AtomicBool::new(false));
let gate_open = open.clone();
let service = DecodeService::new(DECODE_LRU_CAP, Arc::new(move || {
gate_open.load(Ordering::Relaxed)
}));
let service = DecodeService::new(
DECODE_LRU_CAP,
Arc::new(move || gate_open.load(Ordering::Relaxed)),
);
let req = request(&path, Rational::new(1, 10));
assert!(!service.prefetch(req.clone()), "closed gate refuses");
@@ -1136,7 +1158,10 @@ mod tests {
fn wait_idle_barrier_covers_queued_commands() {
pin_legacy_working_space();
let path = test_clip("barrier");
let service = DecodeService::new(DECODE_LRU_CAP, always());
// The barrier test needs four live entries; the production
// hand-off capacity is deliberately tiny (see DECODE_LRU_CAP), so
// this test sizes its own service.
let service = DecodeService::new(4, always());
for n in 0..4 {
assert!(service.prefetch(request(&path, Rational::new(n, 10))));
}
@@ -1158,7 +1183,9 @@ mod tests {
let service = DecodeService::new(DECODE_LRU_CAP, always());
service.shutdown();
service.shutdown(); // idempotent
assert!(service.request(request(&path, Rational::new(0, 1))).is_none());
assert!(service
.request(request(&path, Rational::new(0, 1)))
.is_none());
assert!(!service.prefetch(request(&path, Rational::new(0, 1))));
assert!(!service.wait_idle());
let _ = std::fs::remove_file(&path);
@@ -1195,6 +1222,7 @@ mod tests {
distance: frame,
version: 0,
},
cancelled: None,
}
}
@@ -1302,15 +1330,29 @@ mod tests {
cache_timebase: None,
footage: None,
montage: vec![
clip("covered.mp4", Rational::new(0, 1), Rational::new(1, 1), Rational::new(2, 1)),
clip("outside.mp4", Rational::new(1, 1), Rational::new(2, 1), Rational::new(0, 1)),
clip(
"covered.mp4",
Rational::new(0, 1),
Rational::new(1, 1),
Rational::new(2, 1),
),
clip(
"outside.mp4",
Rational::new(1, 1),
Rational::new(2, 1),
Rational::new(0, 1),
),
],
adjustments: Vec::new(),
};
let requests = playback_decode_requests(&params, Rational::new(5, 10));
assert_eq!(requests.len(), 1, "only the covering clip is prefetched");
assert_eq!(requests[0].filename, "covered.mp4");
assert_eq!(requests[0].time, Rational::new(5, 2), "media_in + (time - in)");
assert_eq!(
requests[0].time,
Rational::new(5, 2),
"media_in + (time - in)"
);
assert_eq!(requests[0].size, (64, 32));
assert_eq!(requests[0].format, PixelFormat::F32);
+13 -5
View File
@@ -465,6 +465,7 @@ impl TicketArena {
let params = Arc::new(params);
let producer = self.producer.clone();
let slot_done = slot.clone();
let slot_cancelled = slot.clone();
let job = crate::worker::Job {
node_identity: params.viewer,
time: params.time,
@@ -473,6 +474,13 @@ impl TicketArena {
produce: producer,
done: Box::new(move |result| slot_done.finish(result)),
schedule,
// Audit B: let the dispatcher skip producing a frame whose
// ticket was cancelled after posting (exactly-once delivery is
// unchanged — `finish` still fires, here with the same
// `Error::State` a cancelled result would get).
cancelled: Some(Arc::new(move || {
slot_cancelled.cancel.load(Ordering::Acquire)
})),
};
if !self.dispatch.post(job) {
// Backend is gone (shutdown raced the submit): deliver now.
@@ -507,11 +515,7 @@ impl TicketArena {
/// Submit a Background-priority frame (M15 S2 exports/precache): the
/// scheduler renders it whenever no Seek/Playback work is pending.
pub fn submit_video_background(
&self,
params: VideoTicketParams,
done: Completion,
) -> TicketId {
pub fn submit_video_background(&self, params: VideoTicketParams, done: Completion) -> TicketId {
let id = self.next_id();
self.submit_video_background_with_id(id, params, done)
}
@@ -590,6 +594,7 @@ impl TicketArena {
let make_job = |slot_done: Arc<TicketSlot>| {
let ap_job = ap.clone();
let ap_prod = ap.clone();
let slot_cancelled = slot_done.clone();
let producer: Producer = Arc::new(move |_, _| eval::render_audio_samples(&ap_prod));
crate::worker::Job {
node_identity: viewer,
@@ -612,6 +617,9 @@ impl TicketArena {
produce: producer,
done: Box::new(move |result| slot_done.finish(result)),
schedule: JobSchedule::seek(),
cancelled: Some(Arc::new(move || {
slot_cancelled.cancel.load(Ordering::Acquire)
})),
}
};
let job = make_job(slot.clone());
+45 -22
View File
@@ -69,6 +69,11 @@ pub struct Job {
pub done: Completion,
/// Scheduler hints (M15 S2). Defaults to a Seek single-frame request.
pub schedule: JobSchedule,
/// Mid-flight cancellation probe (audit B): when it returns true the
/// dispatcher must finish the job with `Error::State` without running
/// the producer. The ticket arena installs the slot's cancel atom;
/// hand-built jobs (tests, non-ticket callers) pass `None`.
pub cancelled: Option<Arc<dyn Fn() -> bool + Send + Sync>>,
}
/// Scheduler hints a posted job carries (M15 S2). The process dispatcher
@@ -234,8 +239,14 @@ impl InlineDispatcher {
/// Run one job on the calling thread: the producer, then its completion
/// (used by the inline dispatcher and by [`crate::pipeline::PipelineBackend`],
/// which runs the same producer on its render thread).
/// which runs the same producer on its render thread). A job the arena
/// already cancelled (audit B) is short-circuited with `Error::State` —
/// a cancelled frame must not burn render/GPU work only to be discarded.
pub(crate) fn execute_job(job: Job) {
if job.cancelled.as_ref().is_some_and(|cancelled| cancelled()) {
(job.done)(Err(Error::State));
return;
}
let result = catch_unwind(AssertUnwindSafe(|| (job.produce)(job.time, &job.params)))
.unwrap_or_else(|_| Err(Error::Failed("frame producer panicked".into())));
(job.done)(result);
@@ -302,8 +313,8 @@ impl GraphSnapshotStore {
"oakrender-snapshots-{}-{:x}",
std::process::id(),
{
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
SEQ.fetch_add(1, Ordering::Relaxed)
}
));
@@ -350,9 +361,10 @@ impl GraphSnapshotStore {
// Atomic staging: temp file + rename. The rename is a single
// directory entry swap, so a concurrent worker load observes
// either the old file or the complete new one.
let tmp = self
.dir
.join(format!("graph-{uuid}-{revision}.{}.tmp", std::process::id()));
let tmp = self.dir.join(format!(
"graph-{uuid}-{revision}.{}.tmp",
std::process::id()
));
std::fs::write(&tmp, &xml)
.map_err(|e| Error::Failed(format!("write snapshot temp: {e}")))?;
if let Err(e) = std::fs::rename(&tmp, &path) {
@@ -390,9 +402,10 @@ impl GraphSnapshotStore {
let path = self.dir.join(format!("graph-{uuid}-{revision}.xml"));
let path_str = path.to_string_lossy().into_owned();
// Atomic staging: temp file + rename (see [`acquire`]).
let tmp = self
.dir
.join(format!("graph-{uuid}-{revision}.{}.tmp", std::process::id()));
let tmp = self.dir.join(format!(
"graph-{uuid}-{revision}.{}.tmp",
std::process::id()
));
std::fs::write(&tmp, &xml)
.map_err(|e| Error::Failed(format!("write snapshot temp: {e}")))?;
if let Err(e) = std::fs::rename(&tmp, &path) {
@@ -466,13 +479,13 @@ impl Default for GraphSnapshotStore {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
use std::time::Duration;
use super::*;
use std::sync::mpsc;
use std::time::Duration;
use oak_core::texture::Texture;
use oak_core::texture::Texture;
fn job(tag: u64, tx: mpsc::Sender<u64>, gate: Option<Arc<AtomicBool>>) -> Job {
fn job(tag: u64, tx: mpsc::Sender<u64>, gate: Option<Arc<AtomicBool>>) -> Job {
let produce: Producer = Arc::new(move |_, _| {
if let Some(g) = &gate {
if g.load(Ordering::Acquire) {
@@ -505,6 +518,7 @@ mod tests {
let _ = tx.send(tag);
}),
schedule: JobSchedule::seek(),
cancelled: None,
}
}
@@ -552,7 +566,8 @@ mod tests {
let (tx, rx) = mpsc::channel();
for _ in 0..4 {
let tx = tx.clone();
let p: Producer = Arc::new(|_, _| Ok(crate::ticket::TicketPayload::Video(Texture::dummy())));
let p: Producer =
Arc::new(|_, _| Ok(crate::ticket::TicketPayload::Video(Texture::dummy())));
d.post(Job {
node_identity: 1,
time: Rational::new(0, 1),
@@ -576,6 +591,7 @@ mod tests {
let _ = tx.send(r.is_err());
}),
schedule: JobSchedule::seek(),
cancelled: None,
});
}
drop(tx);
@@ -585,7 +601,10 @@ mod tests {
delivered.push(err);
}
assert_eq!(delivered.len(), 4, "all queued completions fire");
assert!(delivered.iter().all(|&e| e), "queued jobs cancel at shutdown");
assert!(
delivered.iter().all(|&e| e),
"queued jobs cancel at shutdown"
);
}
#[test]
@@ -594,7 +613,8 @@ mod tests {
let (tx, rx) = mpsc::channel();
let tx1 = tx.clone();
let boom: Producer = Arc::new(|_, _| panic!("boom"));
let ok: Producer = Arc::new(|_, _| Ok(crate::ticket::TicketPayload::Video(Texture::dummy())));
let ok: Producer =
Arc::new(|_, _| Ok(crate::ticket::TicketPayload::Video(Texture::dummy())));
let params = Arc::new(VideoTicketParams {
viewer: 0,
project: String::new(),
@@ -620,6 +640,7 @@ mod tests {
let _ = tx1.send(1u64);
}),
schedule: JobSchedule::seek(),
cancelled: None,
});
d.post(Job {
node_identity: 1,
@@ -632,6 +653,7 @@ mod tests {
let _ = tx.send(2u64);
}),
schedule: JobSchedule::seek(),
cancelled: None,
});
let mut got = Vec::new();
while let Ok(v) = rx.recv_timeout(Duration::from_secs(5)) {
@@ -681,10 +703,7 @@ mod tests {
// First snapshot: the default project (working space ACEScg).
let p1 = store.acquire(&project, 1).unwrap();
let before = std::fs::read_to_string(&p1).unwrap();
assert!(
std::path::Path::new(&p1).exists(),
"snapshot file written"
);
assert!(std::path::Path::new(&p1).exists(), "snapshot file written");
assert_eq!(store.refs(&p1), 1);
// A settings change with no revision bump: plain acquire must NOT
@@ -695,7 +714,11 @@ mod tests {
}
let p2 = store.acquire(&project, 1).unwrap();
assert_eq!(p1, p2, "same (uuid, revision) key reuses the file");
assert_eq!(std::fs::read_to_string(&p1).unwrap(), before, "acquire never rewrites");
assert_eq!(
std::fs::read_to_string(&p1).unwrap(),
before,
"acquire never rewrites"
);
store.release(&p2);
// ...while acquire_rewrite rewrites it in place at the same key.