render: the M1 thread pipeline — one render thread, one decode thread

docs/zh/plans/render-pipeline-threads.md M1: an in-process
alternative to the worker-process pool, behind OAK_PIPELINE=threads
(processes stays the default and is fully retained).

- pipeline.rs: PipelineBackend implements JobDispatch over a single
  render thread draining a bounded FIFO (cap 8; blocking post with
  condvar backpressure and a one-ahead exception for re-posts from
  the render thread itself; shutdown drains with Error::State like
  the inline dispatcher). The DecodeService is a single decode
  thread behind a bounded command queue with a real LRU (tick-based
  eviction), rendezvous requests (None on shutdown -> the caller
  decodes inline), prefetch gated on render-queue room, and a Sync
  barrier; it installs into a process-wide slot that eval's footage
  path consults per frame (no service -> the synchronous decode it
  always was).
- The manager gains RenderBackendChoice::Pipeline; init() reads
  OAK_PIPELINE (threads -> pipeline, anything else -> the process
  pool), audio stays deliberately inline.
- Present mapping: the UI thread consumes through the ticket
  completion, unchanged — no fourth thread is invented.
- Tests: decode-service unit tests (rendezvous, LRU hit/eviction,
  error propagation, backpressure gate) plus a six-case integration
  suite matrixed over inline vs pipeline — consecutive-frame and
  out-of-order seek pixel equality asserted byte for byte, with
  decode counters proving the service (not the caller) did the
  codec work.
This commit is contained in:
2026-09-11 19:37:27 +08:00
parent 23174512c0
commit a5b0b2a1b1
7 changed files with 1789 additions and 15 deletions
+48 -2
View File
@@ -1059,6 +1059,23 @@ const MAX_CACHED_DECODERS: usize = 6;
/// LRU tick source for [`DECODERS`].
static DECODER_TICK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// Count of [`render_footage_frame_inner`] entries, i.e. of real codec work
/// on the footage path (frame-cache hits and decode-service LRU hits do not
/// count). Visible for the decode-service tests, which use it to tell a
/// cached frame from a re-decode.
static DECODE_INVOCATIONS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// The number of real footage decodes since the last
/// [`reset_decode_invocations`] (or process start).
pub fn decode_invocations() -> u64 {
DECODE_INVOCATIONS.load(std::sync::atomic::Ordering::Relaxed)
}
/// Reset the [`decode_invocations`] counter to zero.
pub fn reset_decode_invocations() {
DECODE_INVOCATIONS.store(0, std::sync::atomic::Ordering::Relaxed);
}
/// Cap on decoded FRAMES cached per process (release builds of the graph
/// renderer re-decode every frame the pre-render window pulls — each is a
/// seek+decode on the shared decoder session, which serializes the graph
@@ -1175,6 +1192,12 @@ fn open_decoder(filename: &str, stream_index: i32) -> Result<Arc<dyn oak_codec::
/// Decode the footage frame at `time` and copy/scale it into an
/// oakrender F32 frame of `(w, h)`.
///
/// While a [`crate::pipeline::DecodeService`] is installed (the pipeline
/// backend's decode thread) the decode is a rendezvous with that service,
/// which caches frames and keeps the codec calls off the caller's thread;
/// with no service installed this is the synchronous decode it has always
/// been. Either way the pixels are the same.
pub fn render_footage_frame(
filename: &str,
stream_index: i32,
@@ -1183,7 +1206,24 @@ pub fn render_footage_frame(
format: PixelFormat,
) -> Result<Texture> {
let started = std::time::Instant::now();
let result = render_footage_frame_inner(filename, stream_index, time, size, format);
let result = match crate::pipeline::decode_service() {
Some(service) => {
let request = crate::pipeline::DecodeRequest {
filename: filename.to_string(),
stream_index,
time,
size,
format,
};
match service.request(request) {
Some(result) => result,
// The service went away (shutdown) mid-flight: decode here
// rather than failing the frame.
None => render_footage_frame_inner(filename, stream_index, time, size, format),
}
}
None => render_footage_frame_inner(filename, stream_index, time, size, format),
};
if std::env::var_os("OAK_PERF").is_some() {
eprintln!(
"[decode] {:?} s{} time {}/{} ({:.3}s) size {:?} -> {:.3}s {:?}",
@@ -1200,7 +1240,10 @@ pub fn render_footage_frame(
result
}
fn render_footage_frame_inner(
/// The synchronous decode behind [`render_footage_frame`] (frame-LRU →
/// decoder session → codec → F32 frame). `pub(crate)` because the decode
/// service runs exactly this on its own thread.
pub(crate) fn render_footage_frame_inner(
filename: &str,
stream_index: i32,
time: Rational,
@@ -1224,6 +1267,9 @@ fn render_footage_frame_inner(
return Ok(Texture::wrap_frame(frame));
}
}
// Past the frame cache: this call really goes to the codec (the
// decode-service tests read this counter to prove it).
DECODE_INVOCATIONS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let decoder = open_decoder(filename, stream_index)?;
let params = RetrieveVideoParams {
stream: CodecStream::with_block(filename.to_string(), stream_index, None),