feat(oakrender): render-process isolation S3 - audio over shm, per-ticket slot formats, tuning

- Audio tickets join the process backend: render_audio_batch wire
  message, workers mix straight into shm slots (SLOT_FORMAT_AUDIO_F32),
  ShmAudio payload with release semantics, crash isolation covers audio
  renders; playback audio uses an async 4-chunk prefetch drained on the
  UI tick (also fixes the sub-60fps chunk truncation bug); oversized
  ranges and dispatcher outages fall back to in-process inline.
- Per-ticket slot formats: force_format is honored (exports request
  F32 slots, dropping the BGRA8 round-trip and its 8-bit quantization);
  segments grow on demand via worker-idle rebuild with generation
  handoff; the scheduler filters over-capacity tickets.
- Adaptive defaults: 128-256MB/worker segment budgets drive slots per
  worker, batch size follows workers/slots; bench_process example
  measures throughput and adjacent-frame completion deltas
  (e.g. 4 workers: 841 fps, 4.6ms mean delta).
This commit is contained in:
2026-08-19 00:42:03 +08:00
parent 194d761ade
commit adf2cef32c
18 changed files with 2156 additions and 185 deletions
+124 -7
View File
@@ -553,6 +553,20 @@ pub fn render_footage_frame(
pub fn render_audio_samples(
params: &crate::ticket::AudioTicketParams,
) -> Result<crate::ticket::TicketPayload> {
let (rate, layout, channels, total_frames) = audio_layout(params)?;
let mut acc = vec![0.0f32; total_frames.saturating_mul(channels as usize)];
mix_audio_montage(params, rate, channels, total_frames, &mut acc)?;
Ok(crate::ticket::TicketPayload::Audio(crate::ticket::AudioSamples {
samples: acc,
sample_rate: rate,
channel_layout: layout,
channel_count: channels,
}))
}
/// The output layout an audio render produces: `(sample_rate,
/// channel_layout, channel_count, total_sample_frames)`.
fn audio_layout(params: &crate::ticket::AudioTicketParams) -> Result<(i32, u64, i32, usize)> {
let rate = params.sample_rate.max(1);
let channels = params.channel_layout.count_ones().max(1) as i32;
let duration = params.range.out() - params.range.in_();
@@ -565,8 +579,30 @@ pub fn render_audio_samples(
return Err(Error::Invalid);
}
let total_frames = (seconds * rate as f64).round() as usize;
let mut acc = vec![0.0f32; total_frames.saturating_mul(channels as usize)];
Ok((rate, params.channel_layout, channels, total_frames))
}
/// The byte length (interleaved f32, little-endian) an audio render of
/// `params` writes into a shm slot — the worker's slot-geometry check
/// (M15 S3). Mirrors [`render_audio_samples_into`]'s layout math.
pub fn audio_samples_byte_len(params: &crate::ticket::AudioTicketParams) -> Result<usize> {
let (_rate, _layout, channels, total_frames) = audio_layout(params)?;
Ok(total_frames
.saturating_mul(channels as usize)
.saturating_mul(4))
}
/// Mix the audio montage into `acc` (`total_frames * channels` samples,
/// zero-initialized by the caller). Shared by the heap
/// [`render_audio_samples`] and the shm-slot [`render_audio_samples_into`]
/// paths so the decode/mix logic exists once.
fn mix_audio_montage(
params: &crate::ticket::AudioTicketParams,
rate: i32,
channels: i32,
total_frames: usize,
acc: &mut [f32],
) -> Result<()> {
for clip in &params.montage {
// Overlap of the clip with the requested range.
let in_time = params.range.in_().max(clip.in_time);
@@ -603,13 +639,32 @@ pub fn render_audio_samples(
acc[start_frame * channels as usize + i] += buf[i] * clip.gain;
}
}
Ok(())
}
Ok(crate::ticket::TicketPayload::Audio(crate::ticket::AudioSamples {
samples: acc,
sample_rate: rate,
channel_layout: params.channel_layout,
channel_count: channels,
}))
/// Render the audio montage over `params.range` directly into `dst` as
/// little-endian f32 bytes (M15 S3 worker seam): the render worker passes
/// a shared-memory slot slice as `dst`, so the samples land in the slot
/// with no staging allocation. `dst.len()` must hold
/// `frame_count * channels * 4` bytes.
pub fn render_audio_samples_into(
params: &crate::ticket::AudioTicketParams,
dst: &mut [u8],
) -> Result<()> {
let (rate, _layout, channels, total_frames) = audio_layout(params)?;
let need = total_frames
.saturating_mul(channels as usize)
.saturating_mul(4);
if dst.len() < need {
return Err(Error::NoMem);
}
let mut acc = vec![0.0f32; total_frames.saturating_mul(channels as usize)];
mix_audio_montage(params, rate, channels, total_frames, &mut acc)?;
// Interleaved f32 -> little-endian bytes in the slot.
for (out, sample) in dst[..need].chunks_exact_mut(4).zip(&acc) {
out.copy_from_slice(&sample.to_le_bytes());
}
Ok(())
}
/// Bilinear scale an F32-RGBA image (row-major with per-row strides).
@@ -1048,4 +1103,66 @@ mod tests {
}
}
use std::sync::Arc;
// ---- Audio (M12 P1 / M15 S3) -----------------------------------------
fn audio_params(range: TimeRange) -> crate::ticket::AudioTicketParams {
crate::ticket::AudioTicketParams {
viewer: 1,
range,
sample_rate: 48000,
channel_layout: 0x3,
montage: Vec::new(),
}
}
#[test]
fn render_audio_samples_produces_silence_for_empty_montage() {
// M12 P1: an empty montage renders total silence at the requested
// layout.
let params = audio_params(TimeRange::new(Rational::new(0, 1), Rational::new(1, 24)));
match render_audio_samples(&params).unwrap() {
crate::ticket::TicketPayload::Audio(samples) => {
// 1/24 s at 48 kHz = 2000 sample frames, stereo.
assert_eq!(samples.sample_rate, 48000);
assert_eq!(samples.channel_count, 2);
assert_eq!(samples.samples.len(), 2000 * 2);
assert!(samples.samples.iter().all(|&v| v == 0.0), "silence");
}
other => panic!("expected Audio payload, got {other:?}"),
}
}
#[test]
fn render_audio_samples_into_matches_heap_path_byte_for_byte() {
// M15 S3: the shm-slot writer must produce exactly the same
// little-endian f32 bytes as the heap path, so a worker's slot and
// the in-process fallback agree for the same montage.
let params = audio_params(TimeRange::new(Rational::new(0, 1), Rational::new(1, 48)));
let heap = match render_audio_samples(&params).unwrap() {
crate::ticket::TicketPayload::Audio(samples) => samples,
other => panic!("expected Audio payload, got {other:?}"),
};
let mut dst = vec![0u8; heap.samples.len() * 4];
render_audio_samples_into(&params, &mut dst).unwrap();
let expected: Vec<u8> = heap
.samples
.iter()
.flat_map(|v| v.to_le_bytes())
.collect();
assert_eq!(dst, expected);
// And the into-path output parses back into the same samples.
let parsed: Vec<f32> = dst
.chunks_exact(4)
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
.collect();
assert_eq!(parsed, heap.samples);
}
#[test]
fn render_audio_samples_into_rejects_small_buffer() {
let params = audio_params(TimeRange::new(Rational::new(0, 1), Rational::new(1, 24)));
let mut dst = [0u8; 8]; // far too small for 2000x2 f32 samples
assert!(render_audio_samples_into(&params, &mut dst).is_err());
}
}
+107 -1
View File
@@ -116,6 +116,9 @@ pub const TYPE_RENDER_BATCH: &str = "render_batch";
pub const TYPE_BATCH_ACCEPTED: &str = "batch_accepted";
/// `"frame_failed"` (protocol v2).
pub const TYPE_FRAME_FAILED: &str = "frame_failed";
/// `"render_audio_batch"` (protocol v2, M15 S3): a batch of audio range
/// pulls rendered into the same shm slot transport as video frames.
pub const TYPE_RENDER_AUDIO_BATCH: &str = "render_audio_batch";
/// Wire-format slot format for 8-bit BGRA frames (M15 S1). The viewer
/// preview path requests BGRA8 so the worker converts its F32 pipeline
@@ -125,6 +128,15 @@ pub const TYPE_FRAME_FAILED: &str = "frame_failed";
/// slot wire format, not a pipeline format.
pub const SLOT_FORMAT_BGRA8: i32 = 100;
/// Wire-format slot format for interleaved f32 audio samples (M15 S3).
/// An audio slot reuses [`FrameSlotMeta`]: `format` is this marker,
/// `channel_count` is the channel count, `linesize` is
/// `channels * 4` (bytes per sample frame), `data_size` is the total
/// sample bytes and `width` carries the output sample rate (Hz). The
/// value lives outside the `PixelFormat` enum range like
/// [`SLOT_FORMAT_BGRA8`].
pub const SLOT_FORMAT_AUDIO_F32: i32 = 101;
/// `handshake` — field-for-field equivalent of `oak_ipc_handshake`
/// (ipc.h). Wire field names match the C++ serializer.
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
@@ -323,6 +335,49 @@ pub struct BatchAcceptedMsg {
pub tickets: Vec<i64>,
}
/// One audio range pull inside a [`RenderAudioBatchMsg`] (M15 S3) — the
/// audio counterpart of [`BatchTicketSpec`]: the main process assigns the
/// destination `slot`, the worker mixes the montage over `[time,
/// time + duration)` at `sample_rate`/`channel_layout` into interleaved
/// f32 and writes it into the slot (wire format
/// [`SLOT_FORMAT_AUDIO_F32`]).
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
#[serde(default)]
pub struct AudioTicketSpec {
/// Ticket id (correlates with frame_ready / frame_failed).
pub ticket: i64,
/// Destination slot index in the worker->main output pool.
pub slot: i32,
/// Range start numerator.
pub time_num: i64,
/// Range start denominator.
pub time_den: i64,
/// Range length numerator.
pub duration_num: i64,
/// Range length denominator.
pub duration_den: i64,
/// Output sample rate (Hz).
pub sample_rate: i32,
/// Output channel layout mask.
pub channel_layout: u64,
/// Channel count (derived from the layout; written into the slot meta).
pub channels: i32,
/// Sequence montage (ordered topmost-last; empty = silence).
pub montage: Vec<WireMontageClip>,
}
/// `render_audio_batch` (main->worker, M15 S3) — a batch of audio range
/// pulls rendered in order, sharing the claim/credit/frame_ready flow of
/// [`RenderBatchMsg`].
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
#[serde(default)]
pub struct RenderAudioBatchMsg {
/// Batch id (correlates with batch_accepted).
pub batch_id: i64,
/// The audio tickets, rendered in order.
pub tickets: Vec<AudioTicketSpec>,
}
/// `frame_failed` (worker->main) — one ticket failed to render; the main
/// process falls back (purple frame) and owns the slot again.
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
@@ -1338,15 +1393,66 @@ mod tests {
assert_eq!(tagged, failed);
}
#[test]
fn render_audio_batch_wire_roundtrip() {
// M15 S3: the audio batch message reuses the claim/credit flow of
// render_batch with a dedicated ticket spec.
let batch = RenderAudioBatchMsg {
batch_id: 5,
tickets: vec![AudioTicketSpec {
ticket: 41,
slot: 0,
time_num: 0,
time_den: 48000,
duration_num: 1600,
duration_den: 48000,
sample_rate: 48000,
channel_layout: 0x3,
channels: 2,
montage: vec![WireMontageClip {
filename: "a.mp4".into(),
stream_index: 0,
in_num: 0,
in_den: 24,
out_num: 48,
out_den: 24,
media_in_num: 10,
media_in_den: 24,
gain: 0.5,
}],
}],
};
let value = serde_json::to_value(&batch).unwrap();
assert_eq!(value["batch_id"], 5);
assert_eq!(value["tickets"][0]["ticket"], 41);
assert_eq!(value["tickets"][0]["slot"], 0);
assert_eq!(value["tickets"][0]["sample_rate"], 48000);
assert_eq!(value["tickets"][0]["channel_layout"], 3);
assert_eq!(value["tickets"][0]["channels"], 2);
assert_eq!(value["tickets"][0]["duration_num"], 1600);
assert_eq!(value["tickets"][0]["montage"][0]["filename"], "a.mp4");
let round: RenderAudioBatchMsg = serde_json::from_value(value).unwrap();
assert_eq!(round, batch);
// Defaults: a bare audio ticket parses (missing fields default).
let bare: AudioTicketSpec = serde_json::from_str(r#"{"ticket":1,"slot":2}"#).unwrap();
assert_eq!(bare.ticket, 1);
assert_eq!(bare.slot, 2);
assert_eq!(bare.sample_rate, 0);
assert!(bare.montage.is_empty());
}
#[test]
fn v2_type_constants_are_stable_wire_names() {
assert_eq!(TYPE_HELLO_CAPS, "hello_caps");
assert_eq!(TYPE_RENDER_BATCH, "render_batch");
assert_eq!(TYPE_BATCH_ACCEPTED, "batch_accepted");
assert_eq!(TYPE_FRAME_FAILED, "frame_failed");
assert_eq!(TYPE_RENDER_AUDIO_BATCH, "render_audio_batch");
assert_eq!(TYPE_SHUTDOWN, "shutdown");
// BGRA8 slot format stays outside the PixelFormat enum range.
// BGRA8 slot format stays outside the PixelFormat enum range; the
// audio slot format follows it (M15 S3).
assert_eq!(SLOT_FORMAT_BGRA8, 100);
assert_eq!(SLOT_FORMAT_AUDIO_F32, 101);
}
// ---- Shared-memory transport -----------------------------------------
+35 -23
View File
@@ -31,7 +31,7 @@ use crate::autocacher::PreviewAutoCacher;
use crate::backend::BackendKind;
use crate::error::{Error, Result};
use crate::eval;
use crate::procpool::{DispatcherConfig, ProcessDispatcher, ShmFrameRef};
use crate::procpool::{DispatcherConfig, ProcessDispatcher, ShmAudioRef, ShmFrameRef};
use crate::ticket::{TicketArena, TicketId};
use crate::worker::{InlineDispatcher, JobDispatch};
@@ -58,9 +58,9 @@ pub enum RenderBackendChoice {
pub struct RenderManager {
/// Video job dispatch (the process dispatcher, M15).
pub dispatch: Arc<dyn JobDispatch>,
/// Audio job dispatch — kept on main-process inline execution until S3
/// (design §3.7: crash risk is dominated by video plugins, which live
/// in oak-worker).
/// Audio job dispatch (M15 S3: the process dispatcher, like video —
/// audio renders in oak-worker so plugin crashes are isolated; the
/// inline fallback lives in the arena, design §3.7).
pub audio_dispatch: Arc<dyn JobDispatch>,
/// Ticket arena.
pub tickets: Arc<TicketArena>,
@@ -96,27 +96,33 @@ impl RenderManager {
eval::render_produced_frame(time, params)
.map(crate::ticket::TicketPayload::Video)
});
let (dispatch, audio_dispatch): (Arc<dyn JobDispatch>, Arc<dyn JobDispatch>) =
match choice {
RenderBackendChoice::Threads => {
// Test-only inline backend: synchronous execution on the
// calling thread, shared by video and audio.
let inline = InlineDispatcher::sync();
(inline.clone(), inline)
}
RenderBackendChoice::Processes(config) => {
let dispatcher = ProcessDispatcher::new(config)?;
dispatcher.start()?;
// Audio stays on main-process inline execution until S3
// (design §3.7): render_audio_samples runs synchronously
// on the posting thread.
let audio = InlineDispatcher::sync();
(dispatcher, audio)
}
};
let tickets = Arc::new(TicketArena::new_with_audio(
let (dispatch, audio_dispatch, audio_fallback): (
Arc<dyn JobDispatch>,
Arc<dyn JobDispatch>,
Option<Arc<dyn JobDispatch>>,
) = match choice {
RenderBackendChoice::Threads => {
// Test-only inline backend: synchronous execution on the
// calling thread, shared by video and audio.
let inline = InlineDispatcher::sync();
(inline.clone(), inline, None)
}
RenderBackendChoice::Processes(config) => {
let dispatcher = ProcessDispatcher::new(config)?;
dispatcher.start()?;
// M15 S3: audio rendering runs in the worker pool too (a
// plugin crash during an audio render must not take down
// the main process — design §3.7). The inline dispatcher
// stays as the fallback when the process dispatcher is
// unavailable (teardown).
let fallback = InlineDispatcher::sync();
(dispatcher.clone(), dispatcher, Some(fallback))
}
};
let tickets = Arc::new(TicketArena::new_with_audio_fallback(
dispatch.clone(),
audio_dispatch.clone(),
audio_fallback,
producer,
));
*guard = Some(Arc::new(RenderManager {
@@ -173,6 +179,12 @@ impl RenderManager {
self.dispatch.release_frame(frame);
}
/// Release a consumed shm audio frame's slot (M15 S3; see
/// [`RenderManager::release_frame`]).
pub fn release_audio_frame(&self, frame: &ShmAudioRef) {
self.dispatch.release_audio_frame(frame);
}
/// Cancel every pending AND claimed frame of `sequence` (M15 S2
/// preview-window invalidation); their completions fire with
/// `Error::State`. No-op on backends that schedule no window.
+470 -67
View File
@@ -69,13 +69,16 @@ use serde_json::{json, Value};
use crate::error::{Error, Result};
use crate::ipc::{
write_message, BatchAcceptedMsg, FrameFailedMsg, FrameReadyMsg, FrameSlotMeta, FrameSlotPool,
HandshakeMsg, HelloCapsMsg, RenderBatchMsg, BatchTicketSpec, SharedMemoryRegion, ShmMode,
WireMontageClip, SLOT_FORMAT_BGRA8, TYPE_BATCH_ACCEPTED, TYPE_ERROR, TYPE_FRAME_FAILED,
TYPE_FRAME_READY, TYPE_HANDSHAKE, TYPE_HELLO_CAPS,
write_message, AudioTicketSpec, BatchAcceptedMsg, BatchTicketSpec, FrameFailedMsg, FrameReadyMsg,
FrameSlotMeta, FrameSlotPool, HandshakeMsg, HelloCapsMsg, RenderAudioBatchMsg, RenderBatchMsg,
SharedMemoryRegion, ShmMode, WireMontageClip, SLOT_FORMAT_BGRA8, TYPE_BATCH_ACCEPTED,
TYPE_ERROR, TYPE_FRAME_FAILED, TYPE_FRAME_READY, TYPE_HANDSHAKE, TYPE_HELLO_CAPS,
TYPE_RENDER_AUDIO_BATCH,
};
use crate::scheduler::{FrameKey, FrameRequest, PreviewScheduler, SubmitOutcome};
use crate::ticket::{Completion, TicketPayload, TicketResult, VideoTicketParams};
use crate::ticket::{
AudioSamples, AudioTicketParams, Completion, TicketPayload, TicketResult, VideoTicketParams,
};
use crate::worker::{Job, JobDispatch};
/// Protocol version spoken by the dispatcher (v1 base; v2 messages are
@@ -85,7 +88,17 @@ pub const DISPATCH_PROTOCOL_VERSION: i32 = 1;
/// Restart attempts per worker before its tickets fail permanently.
const MAX_RESTARTS: u32 = 5;
/// Default slots per worker segment (design §3.1: 8 slots starting).
/// Maximum audio bytes a process-backend audio ticket may occupy in a shm
/// slot (M15 S3). Larger ranges (long exports) are refused by `post` so
/// the arena falls back to main-process inline rendering — a several-
/// minute export audio buffer does not need (and should not force) a
/// giant shared-memory segment. ~64 MB ≈ 2.9 min of 48 kHz stereo.
const MAX_AUDIO_SLOT_BYTES: usize = 64 * 1024 * 1024;
/// Legacy fixed default slots per worker (design §3.1 "8 slots starting").
/// The M15 S3 adaptive [`default_slots_per_worker`] policy supersedes it
/// for auto-configured dispatchers; kept as the documented starting point
/// and the cap for small frames.
pub const DEFAULT_SLOTS_PER_WORKER: u32 = 8;
/// Frame bytes copied into main-process heap buffers. The playback path
@@ -264,6 +277,81 @@ impl std::fmt::Debug for ShmFrameRef {
}
}
/// Zero-copy handle to rendered audio in a worker segment (M15 S3): what
/// an audio ticket completion carries on the process backend. The samples
/// live in the shm slot as little-endian interleaved f32 (wire format
/// [`crate::ipc::SLOT_FORMAT_AUDIO_F32`]); the consumer reads them with
/// [`ShmAudioRef::samples`] and releases the slot through
/// [`ProcessDispatcher::release_audio_frame`] when done. `sample_rate` /
/// `channel_layout` are carried from the ticket params (they are not part
/// of the slot meta POD).
#[derive(Clone)]
pub struct ShmAudioRef {
/// Worker index owning the segment.
pub worker: u32,
/// Slot index in that segment.
pub slot: u32,
/// Slot metadata (format = `SLOT_FORMAT_AUDIO_F32`).
pub meta: ShmFrameMeta,
/// The segment view (keeps the mapping alive).
pub shm: Arc<ShmRegionView>,
/// Output sample rate (Hz; from the ticket params).
pub sample_rate: i32,
/// Output channel layout mask (from the ticket params).
pub channel_layout: u64,
/// Channel count (also in the slot meta).
pub channel_count: i32,
}
impl ShmAudioRef {
/// View the same slot as a generic [`ShmFrameRef`] (slot release paths
/// that are shared with video frames).
pub fn frame_ref(&self) -> ShmFrameRef {
ShmFrameRef {
worker: self.worker,
slot: self.slot,
meta: self.meta.clone(),
shm: self.shm.clone(),
}
}
/// Copy the interleaved f32 samples out of the slot (the counted
/// copy path — audio bytes must outlive the slot to reach the output
/// device / encoder).
pub fn samples(&self) -> Vec<f32> {
let bytes = self.shm.slot_bytes(self.slot);
let valid = bytes
.get(..self.meta.data_size.max(0) as usize)
.unwrap_or(&[]);
valid
.chunks_exact(4)
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
.collect()
}
/// The decoded [`AudioSamples`] (sample rate / layout from the ticket
/// params, not the slot).
pub fn to_audio_samples(&self) -> AudioSamples {
AudioSamples {
samples: self.samples(),
sample_rate: self.sample_rate,
channel_layout: self.channel_layout,
channel_count: self.channel_count,
}
}
}
impl std::fmt::Debug for ShmAudioRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ShmAudioRef")
.field("worker", &self.worker)
.field("slot", &self.slot)
.field("sample_rate", &self.sample_rate)
.field("channel_count", &self.channel_count)
.finish()
}
}
// ---------------------------------------------------------------------------
// Slot pixel conversions (M15 S2)
// ---------------------------------------------------------------------------
@@ -342,6 +430,41 @@ pub fn default_worker_count(slots_per_worker: u32, slot_bytes: usize) -> usize {
by_cores.min(by_mem).max(1)
}
/// Slot-count policy when a segment grows (M15 S3 grow-on-demand): cap
/// the per-worker segment memory at `GROWN_SEGMENT_BUDGET`, never drop
/// below 2 slots (enough to keep a worker flowing), never exceed the
/// current count.
pub fn default_slots_for_bytes(slot_bytes: usize, current_slots: u32) -> u32 {
/// Per-worker segment budget for a grown segment (256 MiB).
const GROWN_SEGMENT_BUDGET: usize = 256 * 1024 * 1024;
let by_mem = (GROWN_SEGMENT_BUDGET / slot_bytes.max(1)).max(2) as u32;
by_mem.min(current_slots).max(2)
}
/// Default slots per worker segment (M15 S3 adaptive policy): the
/// segment is sized so per-worker shared memory stays within
/// `DEFAULT_SEGMENT_BUDGET` (128 MiB), bounded to `[2, 8]`. Small preview
/// frames (BGRA8 1080p ≈ 8.3 MB) get the full 8 slots (~66 MB); F32 1080p
/// (≈ 33 MB) drops to 3; F32 4K (≈ 132 MB) to 2. The worker-count policy
/// then bounds the whole pool against RAM/4.
pub fn default_slots_per_worker(slot_bytes: usize) -> u32 {
const DEFAULT_SEGMENT_BUDGET: usize = 128 * 1024 * 1024;
const MIN_SLOTS: u32 = 2;
const MAX_SLOTS: u32 = 8;
((DEFAULT_SEGMENT_BUDGET / slot_bytes.max(1)).max(MIN_SLOTS as usize) as u32)
.clamp(MIN_SLOTS, MAX_SLOTS)
}
/// Default batch size B (M15 S3 adaptive policy): the design figure
/// `120 / workers` (a full playback pre-render window split across the
/// pool), capped at the per-worker slot count — credit caps a batch at
/// the free slots anyway, so a B larger than the slots just wastes a
/// claim round trip.
pub fn default_batch_size(workers: usize, slots: u32) -> usize {
let design = (120 / workers.max(1)).max(1);
design.min(slots.max(1) as usize)
}
/// Physical memory in bytes (macOS `hw.memsize`, Linux `sysconf`).
fn physical_memory_bytes() -> Option<u64> {
#[cfg(target_os = "macos")]
@@ -390,7 +513,8 @@ pub struct DispatcherConfig {
pub worker_bin: Option<PathBuf>,
/// Worker process count. `0` = the [`default_worker_count`] policy.
pub workers: usize,
/// Output slots per worker segment. `0` = 8.
/// Output slots per worker segment. `0` = the
/// [`default_slots_per_worker`] policy (adaptive to the frame size).
pub slots_per_worker: u32,
/// Frame width of the segment geometry. `0` = 1920.
pub width: i32,
@@ -399,7 +523,8 @@ pub struct DispatcherConfig {
/// Slot wire format: an `oakcore_rs::PixelFormat` int or
/// [`SLOT_FORMAT_BGRA8`]. Default BGRA8 (the viewer preview path).
pub slot_format: i32,
/// Batch size `B`. `0` = `max(1, 120 / workers)`.
/// Batch size `B`. `0` = the [`default_batch_size`] policy (adaptive
/// to workers and slots).
pub batch_size: usize,
/// Graph snapshot path sent to every worker via `load_graph` after
/// the handshake (`None` = no graph).
@@ -426,12 +551,10 @@ impl Default for DispatcherConfig {
impl DispatcherConfig {
fn normalize(&self) -> DispatcherConfig {
// Only the geometry defaults are resolved here; the adaptive
// policies (workers / slots / batch size) are resolved in
// `ProcessDispatcher::new` where slot_bytes is known.
let mut c = self.clone();
c.slots_per_worker = if c.slots_per_worker == 0 {
DEFAULT_SLOTS_PER_WORKER
} else {
c.slots_per_worker
};
c.width = if c.width == 0 { 1920 } else { c.width };
c.height = if c.height == 0 { 1080 } else { c.height };
c
@@ -476,6 +599,9 @@ struct WorkerHandle {
child: Option<Child>,
stdin: Option<std::process::ChildStdin>,
shm: Arc<ShmRegionView>,
/// Current per-slot data capacity (grows on demand, M15 S3: a ticket
/// requesting F32 or a larger frame rebuilds the segment first).
slot_bytes: usize,
/// FIFO mirror of the shm free ring's contents (the credit).
free_slots: VecDeque<u32>,
/// Dispatched ticket -> assigned slot (awaiting frame_ready).
@@ -488,10 +614,20 @@ struct WorkerHandle {
restarts: u32,
spawned_at: Instant,
accepted_batches: u64,
/// True between a segment grow (M15 S3) and the worker's hello_caps
/// re-attach: the dispatcher must not send new batches while the worker
/// is still attached to the old pool.
reconfiguring: bool,
}
impl WorkerHandle {
fn shell(index: usize, generation: u64, shm: Arc<ShmRegionView>, slots: u32) -> WorkerHandle {
fn shell(
index: usize,
generation: u64,
shm: Arc<ShmRegionView>,
slots: u32,
slot_bytes: usize,
) -> WorkerHandle {
WorkerHandle {
index,
generation,
@@ -499,6 +635,7 @@ impl WorkerHandle {
child: None,
stdin: None,
shm,
slot_bytes,
free_slots: (0..slots).collect(),
outstanding: HashMap::new(),
held: HashSet::new(),
@@ -508,6 +645,7 @@ impl WorkerHandle {
restarts: 0,
spawned_at: Instant::now(),
accepted_batches: 0,
reconfiguring: false,
}
}
}
@@ -519,6 +657,9 @@ impl WorkerHandle {
struct PendingTicket {
key: FrameKey,
params: Arc<VideoTicketParams>,
/// Audio ticket params when this ticket is an audio range pull (M15
/// S3); `None` for video tickets.
audio: Option<Arc<AudioTicketParams>>,
done: Option<Completion>,
}
@@ -533,6 +674,10 @@ struct Inner {
next_ticket: i64,
events_rx: mpsc::Receiver<WorkerEvent>,
events_tx: mpsc::Sender<WorkerEvent>,
/// Segment rebuild generation (M15 S3 grow-on-demand geometry): bumped
/// on every per-worker segment resize so re-created segments never
/// reuse the name of a live mapping.
seg_generation: u64,
started: bool,
shutting_down: bool,
}
@@ -550,18 +695,28 @@ pub struct ProcessDispatcher {
impl ProcessDispatcher {
/// Build a dispatcher from `config` (does not spawn; call
/// [`ProcessDispatcher::start`]).
/// [`ProcessDispatcher::start`]). The adaptive policies resolve here
/// (M15 S3): slots scale with the frame's slot bytes, workers with
/// cores/RAM, batch size with workers and slots.
pub fn new(config: DispatcherConfig) -> Result<Arc<ProcessDispatcher>> {
let config = config.normalize();
let slots = config.slots_per_worker;
let slot_bytes = slot_bytes_for(config.width, config.height, config.slot_format);
let slots = if config.slots_per_worker == 0 {
default_slots_per_worker(slot_bytes)
} else {
config.slots_per_worker
};
let workers = if config.workers == 0 {
default_worker_count(slots, slot_bytes)
} else {
config.workers
};
let batch_size = if config.batch_size == 0 {
default_batch_size(workers, slots)
} else {
config.batch_size
};
let bin = resolve_worker_bin(&config)?;
let batch_size = config.batch_size;
let (events_tx, events_rx) = mpsc::channel();
Ok(Arc::new(ProcessDispatcher {
inner: Mutex::new(Inner {
@@ -575,6 +730,7 @@ impl ProcessDispatcher {
next_ticket: 1,
events_rx,
events_tx,
seg_generation: 0,
started: false,
shutting_down: false,
}),
@@ -697,6 +853,12 @@ impl ProcessDispatcher {
unsafe { handle.shm.pool().release(frame.slot) };
}
/// Release a consumed audio frame's slot (M15 S3) — the audio
/// counterpart of [`ProcessDispatcher::release_frame`].
pub fn release_audio_frame(&self, frame: &ShmAudioRef) {
self.release_frame(&frame.frame_ref());
}
/// Cancel one frame request (pending or in flight). The completion
/// fires with `Error::State` exactly once; a late frame_ready for an
/// in-flight cancel recycles the slot silently.
@@ -780,7 +942,9 @@ impl ProcessDispatcher {
// 3. Interleaved batch claims + dispatch (free slots = credit).
for i in 0..inner.workers.len() {
if matches!(inner.workers[i].state, WorkerState::Alive) {
if matches!(inner.workers[i].state, WorkerState::Alive)
&& !inner.workers[i].reconfiguring
{
self.dispatch_to(inner, i);
}
}
@@ -805,18 +969,10 @@ impl ProcessDispatcher {
match typ {
TYPE_HANDSHAKE => {
// The worker's startup handshake: answer with the shm
// geometry (protocol v1 flow).
// geometry (protocol v1 flow). A mid-session handshake is a
// segment grow (M15 S3): the worker re-attaches the new pool.
handle.startup_seen = true;
let hs = HandshakeMsg {
protocol_version: DISPATCH_PROTOCOL_VERSION,
shm_key: handle.shm.key().to_string(),
input_shm_key: String::new(),
input_slots: 0,
output_slots: handle.shm.slot_count() as i32,
slot_data_bytes: handle.shm.slot_data_bytes() as i64,
input_slot_data_bytes: 0,
};
if self.send_json(handle, &hs.to_json()).is_err() {
if self.send_json(handle, &handshake_for(handle)).is_err() {
handle.state = WorkerState::Dead;
}
}
@@ -824,6 +980,9 @@ impl ProcessDispatcher {
if let Ok(caps) = serde_json::from_value::<HelloCapsMsg>(msg) {
handle.caps = Some(caps);
handle.state = WorkerState::Alive;
// A re-attach after a segment grow is complete: the
// dispatcher may send batches again.
handle.reconfiguring = false;
// One load_graph right after the first handshake.
if !handle.graph_sent {
if let Some(path) = inner.config.graph_snapshot.clone() {
@@ -913,15 +1072,34 @@ impl ProcessDispatcher {
let key = pt.key;
inner.scheduler.frame_done(&key);
if let Some(done) = pt.done.take() {
fired.push((
done,
Ok(TicketPayload::ShmFrame(ShmFrameRef {
worker: worker as u32,
slot: slot as u32,
meta,
shm,
})),
));
// M15 S3: audio tickets complete with the shm audio
// payload (the consumer reads the slot and releases it);
// video tickets keep the ShmFrame payload.
if let Some(audio) = &pt.audio {
let params = audio.clone();
fired.push((
done,
Ok(TicketPayload::ShmAudio(ShmAudioRef {
worker: worker as u32,
slot: slot as u32,
meta,
shm,
sample_rate: params.sample_rate,
channel_layout: params.channel_layout,
channel_count: params.channel_layout.count_ones().max(1) as i32,
})),
));
} else {
fired.push((
done,
Ok(TicketPayload::ShmFrame(ShmFrameRef {
worker: worker as u32,
slot: slot as u32,
meta,
shm,
})),
));
}
} else {
// Cancelled while in flight: recycle the slot now.
self.recycle_slot(inner, worker, slot as u32);
@@ -977,10 +1155,38 @@ impl ProcessDispatcher {
if credit == 0 {
return;
}
let Some(batch) = inner.scheduler.claim_batch(worker, credit) else {
// Grow-on-demand (M15 S3): if a pending request for this worker
// needs a bigger slot than the segment provides, and the worker
// has no in-flight frames, rebuild its segment first (the worker
// re-attaches on a fresh handshake). While the worker is busy the
// oversized request simply stays pending — claim_batch filters it
// by max_bytes, so it is served after the drain.
let grow = {
let handle = &inner.workers[worker];
if handle.outstanding.is_empty() {
inner
.scheduler
.max_pending_bytes_for_worker(worker, handle.slot_bytes)
} else {
None
}
};
if let Some(need) = grow {
if let Err(e) = self.rebuild_segment(inner, worker, need) {
eprintln!("procpool: worker {worker} segment grow to {need} B failed: {e}");
}
// Stop here: the worker is re-attaching to the new segment
// (hello_caps pending). Dispatch resumes on the next pump
// once `reconfiguring` clears — sending a batch now would
// race the pool swap.
return;
}
let max_bytes = inner.workers[worker].slot_bytes;
let Some(batch) = inner.scheduler.claim_batch(worker, credit, max_bytes) else {
return;
};
let mut wire_tickets = Vec::with_capacity(batch.frames.len());
let mut video_tickets = Vec::with_capacity(batch.frames.len());
let mut audio_tickets: Vec<AudioTicketSpec> = Vec::new();
for req in &batch.frames {
let ticket = req.payload;
let slot = match inner.workers[worker].free_slots.pop_front() {
@@ -991,32 +1197,61 @@ impl ProcessDispatcher {
let Some(pt) = inner.tickets.get(&ticket) else {
continue;
};
wire_tickets.push(build_ticket_spec(
ticket,
slot,
&pt.params,
inner.config.slot_format,
));
if let Some(audio) = &pt.audio {
audio_tickets.push(build_audio_ticket_spec(ticket, slot, audio));
} else {
video_tickets.push(build_ticket_spec(
ticket,
slot,
&pt.params,
inner.config.slot_format,
));
}
}
let msg = RenderBatchMsg {
batch_id: batch.batch_id as i64,
tickets: wire_tickets,
};
// The `type` tag is added by hand: [`RenderBatchMsg`] only
// carries the payload fields (it is the parse-side struct).
let mut value = match serde_json::to_value(&msg) {
Ok(v) => v,
Err(_) => return,
};
if let Some(obj) = value.as_object_mut() {
obj.insert(
"type".to_string(),
Value::String(crate::ipc::TYPE_RENDER_BATCH.to_string()),
);
// A single claim may mix audio and video (different scheduler
// keys in one batch); they are delivered as two messages under
// the same batch id, claimed by the worker in order.
if !video_tickets.is_empty() {
let msg = RenderBatchMsg {
batch_id: batch.batch_id as i64,
tickets: video_tickets,
};
// The `type` tag is added by hand: the parse-side structs only
// carry the payload fields.
let mut value = match serde_json::to_value(&msg) {
Ok(v) => v,
Err(_) => return,
};
if let Some(obj) = value.as_object_mut() {
obj.insert(
"type".to_string(),
Value::String(crate::ipc::TYPE_RENDER_BATCH.to_string()),
);
}
if self.send_json(&mut inner.workers[worker], &value).is_err() {
inner.workers[worker].state = WorkerState::Dead;
return;
}
}
if self.send_json(&mut inner.workers[worker], &value).is_err() {
inner.workers[worker].state = WorkerState::Dead;
return;
if !audio_tickets.is_empty() {
let msg = RenderAudioBatchMsg {
batch_id: batch.batch_id as i64,
tickets: audio_tickets,
};
let mut value = match serde_json::to_value(&msg) {
Ok(v) => v,
Err(_) => return,
};
if let Some(obj) = value.as_object_mut() {
obj.insert(
"type".to_string(),
Value::String(TYPE_RENDER_AUDIO_BATCH.to_string()),
);
}
if self.send_json(&mut inner.workers[worker], &value).is_err() {
inner.workers[worker].state = WorkerState::Dead;
return;
}
}
}
}
@@ -1098,7 +1333,7 @@ impl ProcessDispatcher {
})
.map_err(|e| Error::Failed(format!("spawn reader thread: {e}")))?;
let mut handle = WorkerHandle::shell(index, generation, shm, inner.slots);
let mut handle = WorkerHandle::shell(index, generation, shm, inner.slots, inner.slot_bytes);
handle.child = Some(child);
handle.stdin = stdin;
handle.spawned_at = Instant::now();
@@ -1166,6 +1401,59 @@ impl ProcessDispatcher {
inner.workers[worker].state = WorkerState::Dead;
}
}
/// Grow a worker's segment to `need_bytes` per slot (M15 S3 grow-on-
/// demand geometry, design §3.1 "段按需扩容或重建"): create a fresh
/// segment under a new key (the old mapping stays alive for consumers
/// still holding [`ShmFrameRef`]s into it — they release as stale
/// refs), re-point the handle, reseed the free slots and have the
/// worker re-attach through a fresh handshake. The caller guarantees
/// `outstanding` is empty (no frame is mid-render in the old pool).
/// The worker's hello_caps clears `reconfiguring`, unblocking dispatch.
fn rebuild_segment(&self, inner: &mut Inner, worker: usize, need_bytes: usize) -> Result<()> {
let current_slots = inner.workers[worker].shm.slot_count();
let slots = default_slots_for_bytes(need_bytes, current_slots);
let generation = inner.seg_generation;
inner.seg_generation += 1;
let base = SharedMemoryRegion::make_key(std::process::id() as i64, worker as i32);
let key = format!(
"{base}-g{}-s{generation}",
inner.workers[worker].generation
);
let shm = ShmRegionView::create(&key, slots, need_bytes)?;
{
let handle = &mut inner.workers[worker];
handle.shm = shm;
handle.slot_bytes = need_bytes;
handle.free_slots = (0..slots).collect();
handle.held.clear();
// No new batches until the worker re-attaches the new pool.
handle.reconfiguring = true;
}
let hs = {
let handle = &inner.workers[worker];
handshake_for(handle)
};
if self.send_json(&mut inner.workers[worker], &hs).is_err() {
inner.workers[worker].state = WorkerState::Dead;
}
Ok(())
}
}
/// The handshake reply the dispatcher sends a worker (startup and M15 S3
/// segment-grow re-attach): the worker's current shm geometry.
fn handshake_for(handle: &WorkerHandle) -> Value {
HandshakeMsg {
protocol_version: DISPATCH_PROTOCOL_VERSION,
shm_key: handle.shm.key().to_string(),
input_shm_key: String::new(),
input_slots: 0,
output_slots: handle.shm.slot_count() as i32,
slot_data_bytes: handle.shm.slot_data_bytes() as i64,
input_slot_data_bytes: 0,
}
.to_json()
}
impl JobDispatch for ProcessDispatcher {
@@ -1184,6 +1472,20 @@ impl JobDispatch for ProcessDispatcher {
if inner.shutting_down {
return false;
}
// M15 S3: audio ranges larger than a practical shm slot (export
// of many minutes of audio) are refused here so the arena falls
// back to main-process inline rendering (design §3.7) — the
// process backend stays for the real-time chunks and short
// ranges that fit a segment.
if let Some(audio) = &job.audio {
let too_large = match crate::eval::audio_samples_byte_len(audio) {
Ok(bytes) => bytes > MAX_AUDIO_SLOT_BYTES,
Err(_) => true, // invalid range: let the inline path report it
};
if too_large {
return false;
}
}
let id = inner.next_ticket;
inner.next_ticket += 1;
let frame = job.schedule.frame.unwrap_or(id);
@@ -1192,11 +1494,22 @@ impl JobDispatch for ProcessDispatcher {
frame,
version: job.schedule.version,
};
// M15 S3: per-request slot geometry. Audio tickets need the
// sample bytes of their range; video tickets need the frame
// size x the ticket's wire format (force_format honored).
let slot_bytes = match &job.audio {
Some(audio) => crate::eval::audio_samples_byte_len(audio).unwrap_or(0),
None => {
let (w, h) = job.params.render_size();
slot_bytes_for(w, h, ticket_wire_format(&job.params, inner.config.slot_format))
}
};
inner.tickets.insert(
id,
PendingTicket {
key,
params: job.params,
audio: job.audio,
done: Some(job.done),
},
);
@@ -1205,6 +1518,7 @@ impl JobDispatch for ProcessDispatcher {
priority: job.schedule.priority,
distance: job.schedule.distance,
payload: id,
slot_bytes,
};
match inner.scheduler.submit(request) {
SubmitOutcome::Accepted => {}
@@ -1267,6 +1581,12 @@ impl JobDispatch for ProcessDispatcher {
self.release_frame(frame);
}
/// Release a consumed audio frame's slot (M15 S3; delegates to the
/// inherent release).
fn release_audio_frame(&self, frame: &ShmAudioRef) {
self.release_audio_frame(frame);
}
/// Graceful shutdown: `shutdown` messages, a short drain pumping
/// completions, then kill stragglers; every ticket still open
/// completes with `Error::State`.
@@ -1357,6 +1677,15 @@ fn resolve_worker_bin(config: &DispatcherConfig) -> Result<PathBuf> {
)))
}
/// The wire slot format a video ticket requests: the ticket's forced
/// PixelFormat when set (F32 for exports / full-resolution / scopes, M15
/// S3 — the worker then writes F32 straight into the slot and the export
/// reads it back with no BGRA8 round trip), else the dispatcher's default
/// slot format (BGRA8 for the viewer preview path).
fn ticket_wire_format(params: &VideoTicketParams, config_format: i32) -> i32 {
params.force_format.map(|f| f as i32).unwrap_or(config_format)
}
/// Map ticket params to the wire ticket spec (main assigns `slot`).
fn build_ticket_spec(
ticket: i64,
@@ -1391,7 +1720,7 @@ fn build_ticket_spec(
time_den: params.time.denominator(),
width,
height,
format: slot_format,
format: ticket_wire_format(params, slot_format),
channels: 4,
footage_file,
footage_stream,
@@ -1399,6 +1728,39 @@ fn build_ticket_spec(
}
}
/// Map audio ticket params to the wire audio ticket spec (M15 S3; main
/// assigns `slot`).
fn build_audio_ticket_spec(ticket: i64, slot: u32, params: &AudioTicketParams) -> AudioTicketSpec {
let duration = params.range.out() - params.range.in_();
let montage = params
.montage
.iter()
.map(|c| WireMontageClip {
filename: c.filename.clone(),
stream_index: c.stream_index,
in_num: c.in_time.numerator(),
in_den: c.in_time.denominator(),
out_num: c.out_time.numerator(),
out_den: c.out_time.denominator(),
media_in_num: c.media_in.numerator(),
media_in_den: c.media_in.denominator(),
gain: c.gain,
})
.collect();
AudioTicketSpec {
ticket,
slot: slot as i32,
time_num: params.range.in_().numerator(),
time_den: params.range.in_().denominator(),
duration_num: duration.numerator(),
duration_den: duration.denominator(),
sample_rate: params.sample_rate,
channel_layout: params.channel_layout,
channels: params.channel_layout.count_ones().max(1) as i32,
montage,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1426,10 +1788,51 @@ mod tests {
#[test]
fn config_normalization_defaults() {
let c = DispatcherConfig::default().normalize();
assert_eq!(c.slots_per_worker, DEFAULT_SLOTS_PER_WORKER);
// Geometry defaults resolve here; the adaptive counts stay 0 (auto)
// and resolve in `ProcessDispatcher::new` where slot_bytes is known.
assert_eq!(c.width, 1920);
assert_eq!(c.height, 1080);
assert_eq!(c.slot_format, SLOT_FORMAT_BGRA8);
assert_eq!(c.slots_per_worker, 0);
assert_eq!(c.workers, 0);
assert_eq!(c.batch_size, 0);
}
#[test]
fn slots_policy_adapts_to_slot_size() {
// BGRA8 1080p: the full 8 slots (~66 MB per worker segment).
let bgra8_1080p = slot_bytes_for(1920, 1080, SLOT_FORMAT_BGRA8);
assert_eq!(default_slots_per_worker(bgra8_1080p), 8);
// F32 1080p: drops to 4 (~133 MB per worker segment).
let f32_1080p = slot_bytes_for(1920, 1080, 4);
assert_eq!(default_slots_per_worker(f32_1080p), 4);
// F32 4K: 2 slots (the floor).
let f32_4k = slot_bytes_for(3840, 2160, 4);
assert_eq!(default_slots_per_worker(f32_4k), 2);
// Tiny slots: the cap at 8.
assert_eq!(default_slots_per_worker(16), 8);
}
#[test]
fn batch_size_policy_scales_with_workers_and_slots() {
// 4 workers x 8 slots: the design 120/4 = 30 caps at the 8 slots.
assert_eq!(default_batch_size(4, 8), 8);
// 1 worker x 8 slots: 120/1 = 120 caps at 8.
assert_eq!(default_batch_size(1, 8), 8);
// 2 workers x 4 slots: 120/2 = 60 caps at 4.
assert_eq!(default_batch_size(2, 4), 4);
// 8 workers x 8 slots: 120/8 = 15 caps at 8.
assert_eq!(default_batch_size(8, 8), 8);
}
#[test]
fn grown_segment_slots_stay_bounded() {
// Growing a segment keeps a sane slot count: 8.3 MB slots keep 8;
// 33 MB slots keep 8 (still within the 256 MiB grown budget);
// absurd sizes clamp at 2.
assert_eq!(default_slots_for_bytes(8_300_000, 8), 8);
assert_eq!(default_slots_for_bytes(33_000_000, 8), 8);
assert_eq!(default_slots_for_bytes(1 << 30, 8), 2);
}
#[test]
+79 -17
View File
@@ -86,6 +86,11 @@ pub struct FrameRequest<P> {
pub distance: i64,
/// Caller payload.
pub payload: P,
/// Slot bytes this request needs (frame size x wire format; M15 S3).
/// `claim_batch` skips requests whose bytes exceed the worker's current
/// slot capacity, so the dispatcher can grow the segment first (grow-
/// on-demand geometry, design §3.1).
pub slot_bytes: usize,
}
/// A batch of frames one worker claimed.
@@ -193,12 +198,20 @@ impl<P: Clone> PreviewScheduler<P> {
/// Claim the next batch for `worker`: the worker's interleaved shard
/// (frame number `≡ worker (mod W)`, plus any crash-requeued frames),
/// ordered by priority class / playhead distance / ascending frame,
/// capped at `min(batch_size, credit)`. Returns `None` when nothing
/// is claimable (`credit == 0`, unknown worker, empty shard).
/// capped at `min(batch_size, credit)`. Requests needing more than
/// `max_bytes` of slot space are skipped (they stay pending until the
/// dispatcher grows the segment). Returns `None` when nothing
/// claimable (`credit == 0`, unknown worker, empty shard, all
/// oversized).
///
/// Claimed frames never go to another worker while in flight (no
/// stealing).
pub fn claim_batch(&mut self, worker: usize, credit: usize) -> Option<ClaimedBatch<P>> {
pub fn claim_batch(
&mut self,
worker: usize,
credit: usize,
max_bytes: usize,
) -> Option<ClaimedBatch<P>> {
if worker >= self.workers || credit == 0 {
return None;
}
@@ -208,7 +221,8 @@ impl<P: Clone> PreviewScheduler<P> {
.iter()
.enumerate()
.filter(|(_, e)| {
e.any_worker || e.request.key.frame.rem_euclid(workers as i64) == worker as i64
e.request.slot_bytes <= max_bytes
&& (e.any_worker || e.request.key.frame.rem_euclid(workers as i64) == worker as i64)
})
.map(|(i, _)| i)
.collect();
@@ -346,6 +360,25 @@ impl<P: Clone> PreviewScheduler<P> {
self.pending.len()
}
/// The largest `slot_bytes` among pending requests claimable by
/// `worker` (its shard plus crash-requeued frames) that exceeds
/// `current`, if any. The dispatcher uses this to grow a worker's
/// segment before the next claim (M15 S3 grow-on-demand geometry).
pub fn max_pending_bytes_for_worker(&self, worker: usize, current: usize) -> Option<usize> {
if worker >= self.workers {
return None;
}
let workers = self.workers;
self.pending
.iter()
.filter(|e| {
e.any_worker || e.request.key.frame.rem_euclid(workers as i64) == worker as i64
})
.map(|e| e.request.slot_bytes)
.max()
.filter(|&m| m > current)
}
/// Claimed (in-flight) request count.
pub fn claimed_count(&self) -> usize {
self.claimed.len()
@@ -376,6 +409,7 @@ mod tests {
priority: prio,
distance: frame,
payload: frame as u64,
slot_bytes: 0,
}
}
@@ -386,7 +420,7 @@ mod tests {
loop {
let mut progress = false;
for w in 0..s.workers() {
while let Some(batch) = s.claim_batch(w, 1024) {
while let Some(batch) = s.claim_batch(w, 1024, usize::MAX) {
for f in &batch.frames {
out.push((w, f.key.frame));
}
@@ -448,9 +482,9 @@ mod tests {
s.submit(req(1, f, FramePriority::Playback));
}
// Both workers claim their shards first.
let batch0 = s.claim_batch(0, 8).unwrap();
let batch0 = s.claim_batch(0, 8, 1024).unwrap();
assert_eq!(batch0.frames.len(), 4); // frames 0,2,4,6
let batch1 = s.claim_batch(1, 8).unwrap();
let batch1 = s.claim_batch(1, 8, 1024).unwrap();
assert_eq!(batch1.frames.len(), 4); // frames 1,3,5,7
// Worker 0 crashes: its frames come back...
@@ -461,7 +495,7 @@ mod tests {
assert_eq!(s.crash_requeued(), 4);
// ...and worker 1 (NOT their shard) can claim them all.
let batch = s.claim_batch(1, 8).unwrap();
let batch = s.claim_batch(1, 8, 1024).unwrap();
assert_eq!(batch.frames.len(), 4);
let mut frames: Vec<i64> = batch.frames.iter().map(|f| f.key.frame).collect();
frames.sort_unstable();
@@ -483,7 +517,7 @@ mod tests {
}
s.submit(req(1, 100, FramePriority::Seek));
let batch = s.claim_batch(0, 100).unwrap();
let batch = s.claim_batch(0, 100, 1024).unwrap();
let order: Vec<(FramePriority, i64)> = batch
.frames
.iter()
@@ -516,9 +550,9 @@ mod tests {
s.submit(req(1, f, FramePriority::Playback));
}
// Zero credit claims nothing.
assert!(s.claim_batch(0, 0).is_none());
assert!(s.claim_batch(0, 0, 1024).is_none());
// Credit 3 claims exactly 3 (free slots are the credit).
let batch = s.claim_batch(0, 3).unwrap();
let batch = s.claim_batch(0, 3, 1024).unwrap();
assert_eq!(batch.frames.len(), 3);
assert_eq!(s.pending_count(), 7);
}
@@ -529,19 +563,47 @@ mod tests {
for f in 0..10 {
s.submit(req(1, f, FramePriority::Playback));
}
let batch = s.claim_batch(0, 100).unwrap();
let batch = s.claim_batch(0, 100, 1024).unwrap();
assert_eq!(batch.frames.len(), 4, "batch size B caps the claim");
// Ascending frame order inside the batch.
let frames: Vec<i64> = batch.frames.iter().map(|f| f.key.frame).collect();
assert_eq!(frames, vec![0, 1, 2, 3]);
}
#[test]
fn claim_batch_skips_requests_exceeding_slot_capacity() {
// M15 S3 grow-on-demand: a request needing more bytes than the
// worker's current slot capacity stays pending (the dispatcher can
// grow the segment and claim it later); smaller requests still flow.
let mut s: PreviewScheduler<u64> = PreviewScheduler::new(1, 100);
for f in 0..4 {
let mut r = req(1, f, FramePriority::Playback);
r.slot_bytes = if f == 1 { 33_000_000 } else { 8_300_000 };
s.submit(r);
}
// max_bytes 8_300_000: frame 1 (33 MB) is skipped.
let batch = s.claim_batch(0, 100, 8_300_000).unwrap();
let frames: Vec<i64> = batch.frames.iter().map(|f| f.key.frame).collect();
assert_eq!(frames, vec![0, 2, 3]);
assert_eq!(s.pending_count(), 1, "the oversized frame stays pending");
assert_eq!(
s.max_pending_bytes_for_worker(0, 8_300_000),
Some(33_000_000),
"the dispatcher sees the growth need"
);
// After the segment grows, the oversized frame is claimable.
let batch = s.claim_batch(0, 100, 33_000_000).unwrap();
assert_eq!(batch.frames.len(), 1);
assert_eq!(batch.frames[0].key.frame, 1);
assert_eq!(s.pending_count(), 0);
}
#[test]
fn done_and_failed_drop_the_claim() {
let mut s: PreviewScheduler<u64> = PreviewScheduler::new(1, 4);
s.submit(req(1, 0, FramePriority::Playback));
s.submit(req(1, 1, FramePriority::Playback));
let batch = s.claim_batch(0, 4).unwrap();
let batch = s.claim_batch(0, 4, 1024).unwrap();
assert_eq!(batch.frames.len(), 2);
let k0 = batch.frames[0].key;
let k1 = batch.frames[1].key;
@@ -559,7 +621,7 @@ mod tests {
let r = req(1, 5, FramePriority::Playback);
let key = r.key;
assert!(matches!(s.submit(r), SubmitOutcome::Accepted));
let _ = s.claim_batch(0, 4).unwrap();
let _ = s.claim_batch(0, 4, 1024).unwrap();
// In flight: rejected.
assert!(matches!(
s.submit(req(1, 5, FramePriority::Seek)),
@@ -592,7 +654,7 @@ mod tests {
}
other => panic!("expected Replaced, got {other:?}"),
}
let _ = s.claim_batch(0, 4).unwrap();
let _ = s.claim_batch(0, 4, 1024).unwrap();
assert_eq!(s.claimed_worker(&second_key), Some(0));
}
@@ -602,7 +664,7 @@ mod tests {
for f in 0..6 {
s.submit(req(7, f, FramePriority::Playback));
}
let _ = s.claim_batch(0, 4).unwrap(); // claims 4 of sequence 7
let _ = s.claim_batch(0, 4, 1024).unwrap(); // claims 4 of sequence 7
s.submit(req(8, 0, FramePriority::Playback)); // other sequence
let dropped = s.cancel_sequence(7);
assert_eq!(dropped.len(), 6, "all 6 sequence-7 requests dropped");
@@ -623,6 +685,6 @@ mod tests {
fn unknown_worker_claims_nothing() {
let mut s: PreviewScheduler<u64> = PreviewScheduler::new(2, 4);
s.submit(req(1, 0, FramePriority::Playback));
assert!(s.claim_batch(2, 4).is_none());
assert!(s.claim_batch(2, 4, 1024).is_none());
}
}
+67 -21
View File
@@ -132,6 +132,11 @@ pub enum TicketPayload {
/// process backend): zero copy — the consumer reads the pixels from
/// the mapping and releases the slot through the dispatcher.
ShmFrame(crate::procpool::ShmFrameRef),
/// Rendered audio living in a worker's shared-memory slot (M15 S3):
/// interleaved f32 in `SLOT_FORMAT_AUDIO_F32` slots, consumed with
/// [`crate::procpool::ShmAudioRef::samples`] and released through the
/// dispatcher — the audio counterpart of `ShmFrame`.
ShmAudio(crate::procpool::ShmAudioRef),
}
/// Completion payload: the rendered texture/samples or the failure
@@ -209,6 +214,9 @@ impl TicketSlot {
if let Ok(TicketPayload::ShmFrame(frame)) = &result {
self.dispatch.release_frame(frame);
}
if let Ok(TicketPayload::ShmAudio(audio)) = &result {
self.dispatch.release_audio_frame(audio);
}
result = Err(Error::State);
}
// Publish the result before flipping the state flag: `wait()` only
@@ -255,6 +263,11 @@ pub struct TicketArena {
next: AtomicU64,
dispatch: Arc<dyn JobDispatch>,
audio_dispatch: Arc<dyn JobDispatch>,
/// M15 S3: main-process inline audio fallback, used when the process
/// dispatcher is unavailable (shutting down) — design §3.7. Audio
/// rendering normally runs in oak-worker; the inline fallback keeps
/// playback alive during teardown.
audio_fallback: Option<Arc<dyn JobDispatch>>,
slots: Mutex<HashMap<TicketId, Arc<TicketSlot>>>,
shutting_down: AtomicBool,
producer: Producer,
@@ -273,11 +286,26 @@ impl TicketArena {
video: Arc<dyn JobDispatch>,
audio: Arc<dyn JobDispatch>,
producer: Producer,
) -> Self {
Self::new_with_audio_fallback(video, audio, None, producer)
}
/// Arena with separate video/audio backends plus a main-process inline
/// audio fallback (M15 S3): when `audio` (the process dispatcher)
/// refuses a job, it is re-posted to `audio_fallback` (an
/// [`crate::worker::InlineDispatcher::sync`]); both gone, the ticket
/// cancels.
pub fn new_with_audio_fallback(
video: Arc<dyn JobDispatch>,
audio: Arc<dyn JobDispatch>,
audio_fallback: Option<Arc<dyn JobDispatch>>,
producer: Producer,
) -> Self {
Self {
next: AtomicU64::new(1),
dispatch: video,
audio_dispatch: audio,
audio_fallback,
slots: Mutex::new(HashMap::new()),
shutting_down: AtomicBool::new(false),
producer,
@@ -358,6 +386,7 @@ impl TicketArena {
node_identity: params.viewer,
time: params.time,
params,
audio: None,
produce: producer,
done: Box::new(move |result| slot_done.finish(result)),
schedule,
@@ -454,32 +483,49 @@ impl TicketArena {
// The audio producer captures the montage + output params; the
// video-params field of the job is a dummy (unused by the audio
// path).
// path). M15 S3: `audio` carries the params to the process
// dispatcher, which renders the range in oak-worker via
// render_audio_batch; the producer is the inline-fallback path.
let ap = Arc::new(params);
let viewer = ap.viewer;
let producer: Producer = Arc::new(move |_, _| eval::render_audio_samples(&ap));
let slot_done = slot.clone();
let job = crate::worker::Job {
node_identity: viewer,
time: range.in_(),
params: Arc::new(VideoTicketParams {
viewer,
let make_job = |slot_done: Arc<TicketSlot>| {
let ap_job = ap.clone();
let ap_prod = ap.clone();
let producer: Producer = Arc::new(move |_, _| eval::render_audio_samples(&ap_prod));
crate::worker::Job {
node_identity: viewer,
time: range.in_(),
force_size: None,
force_format: None,
cache: None,
cache_dir: None,
cache_id: None,
cache_timebase: None,
footage: None,
montage: Vec::new(),
}),
produce: producer,
done: Box::new(move |result| slot_done.finish(result)),
schedule: JobSchedule::seek(),
params: Arc::new(VideoTicketParams {
viewer,
time: range.in_(),
force_size: None,
force_format: None,
cache: None,
cache_dir: None,
cache_id: None,
cache_timebase: None,
footage: None,
montage: Vec::new(),
}),
audio: Some(ap_job),
produce: producer,
done: Box::new(move |result| slot_done.finish(result)),
schedule: JobSchedule::seek(),
}
};
let job = make_job(slot.clone());
if !self.audio_dispatch.post(job) {
slot.finish(Err(Error::State));
// The process dispatcher is unavailable (shutting down): fall
// back to main-process inline audio rendering (design §3.7) so
// playback/export audio keeps flowing during teardown.
if let Some(fallback) = &self.audio_fallback {
let job = make_job(slot.clone());
if !fallback.post(job) {
slot.finish(Err(Error::State));
}
} else {
slot.finish(Err(Error::State));
}
}
id
}
+14 -1
View File
@@ -43,7 +43,7 @@ use oakcore_rs::Rational;
use crate::error::{Error, Result};
use crate::procpool::ShmFrameRef;
use crate::scheduler::FramePriority;
use crate::ticket::{Completion, Producer, VideoTicketParams};
use crate::ticket::{AudioTicketParams, Completion, Producer, VideoTicketParams};
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
@@ -57,6 +57,11 @@ pub struct Job {
pub time: Rational,
/// Ticket parameters (size/format overrides).
pub params: Arc<VideoTicketParams>,
/// Audio ticket parameters when this is an audio range pull (M15 S3):
/// the process dispatcher renders it through the worker's
/// `render_audio_batch` path into a shm slot; the in-process inline
/// fallback executes the producer instead. `None` for video jobs.
pub audio: Option<Arc<AudioTicketParams>>,
/// Frame producer (arena-installed; the process backend never invokes
/// it — workers render from the wire spec).
pub produce: Producer,
@@ -134,6 +139,10 @@ pub trait JobDispatch: Send + Sync {
/// process backend holds slots.
fn release_frame(&self, _frame: &ShmFrameRef) {}
/// Release a consumed shm audio frame's slot (M15 S3). Default no-op:
/// only the process backend holds slots.
fn release_audio_frame(&self, _frame: &crate::procpool::ShmAudioRef) {}
/// Cancel every pending AND claimed request of `sequence` (M15 S2
/// preview-window invalidation); their completions fire with
/// `Error::State`. Default no-op: only the process backend schedules.
@@ -372,6 +381,7 @@ mod tests {
footage: None,
montage: Vec::new(),
}),
audio: None,
produce,
done: Box::new(move |r| {
assert!(r.is_ok(), "producer must succeed here");
@@ -441,6 +451,7 @@ mod tests {
footage: None,
montage: Vec::new(),
}),
audio: None,
produce: p,
done: Box::new(move |r| {
let _ = tx.send(r.is_err());
@@ -481,6 +492,7 @@ mod tests {
node_identity: 0,
time: Rational::new(0, 1),
params: params.clone(),
audio: None,
produce: boom,
done: Box::new(move |r| {
assert!(r.is_err());
@@ -492,6 +504,7 @@ mod tests {
node_identity: 1,
time: Rational::new(1, 1),
params,
audio: None,
produce: ok,
done: Box::new(move |r| {
assert!(r.is_ok());