vercel/next.js · #96808
turbo-tasks: execute scheduled tasks inline when they are read
turbopack/crates/turbo-tasks-backend/Cargo.toml3 + / 0 −
@@ -77,6 +77,9 @@ thiserror = { workspace = true } turbo-tasks-malloc = { workspace = true , features = ["custom_allocator"]} rstest = { workspace = true } turbo-tasks-testing = { workspace = true }+# The read-outcome tests assert on the counters, so they need them compiled in. Only for tests —+# normal builds of this crate get the default (disabled) turbo-tasks.+turbo-tasks = { workspace = true, features = ["inline_execution_stats"] } [[bench]]turbopack/crates/turbo-tasks-backend/src/backend/mod.rs45 + / 25 −
@@ -31,9 +31,9 @@ use tracing::{Span, field::display, trace_span}; use turbo_bincode::{TurboBincodeBuffer, new_turbo_bincode_decoder, new_turbo_bincode_encoder}; use turbo_tasks::{ CellId, DynTaskInputsStorage, RawVc, RawVcUnpacked, ReadCellOptions, ReadCellTracking,- ReadConsistency, ReadOutputOptions, ReadTracking, SharedReference, TRANSIENT_TASK_BIT,- TaskExecutionReason, TaskId, TaskPersistence, TaskPriority, TraitTypeId, TurboTasks,- TurboTasksCallApi, TurboTasksPanic, ValueTypeId,+ ReadConsistency, ReadOutcome, ReadOutputOptions, ReadTracking, SharedReference,+ TRANSIENT_TASK_BIT, TaskExecutionReason, TaskId, TaskPersistence, TaskPriority, TraitTypeId,+ TurboTasks, TurboTasksCallApi, TurboTasksPanic, ValueTypeId, backend::{ Backend, CachedTaskType, CachedTaskTypeArc, CellContent, CellHash, TaskExecutionSpec, TransientTaskType, TurboTaskContextError, TurboTaskLocalContextError, TurboTasksError,@@ -486,7 +486,7 @@ impl TurboTasksBackend { reader: Option<TaskId>, options: ReadOutputOptions, turbo_tasks: &TurboTasks<TurboTasksBackend>,- ) -> Result<Result<RawVc, EventListener>> {+ ) -> Result<ReadOutcome<RawVc>> { self.assert_not_persistent_calling_transient(reader, task_id, /* cell_id */ None); let mut ctx = self.execute_context(turbo_tasks);@@ -518,19 +518,25 @@ impl TurboTasksBackend { }) } - fn check_in_progress(+ /// Reports whether the task is already being computed, and — crucially for the reader —+ /// whether a worker has actually started it. A task that is only `Scheduled` can be taken+ /// over and executed by the reader; one that is `InProgress` can only be waited for.+ fn check_in_progress<T>( task: &impl TaskGuard, reader_description: Option<EventDescription>, tracking: ReadTracking,- ) -> Option<std::result::Result<std::result::Result<RawVc, EventListener>, anyhow::Error>>- {+ ) -> Option<Result<ReadOutcome<T>>> { match task.get_in_progress() {- Some(InProgressState::Scheduled { done_event, .. }) => Some(Ok(Err(- listen_to_done_event(reader_description, tracking, done_event),- ))),+ Some(InProgressState::Scheduled { done_event, .. }) => {+ Some(Ok(ReadOutcome::Scheduled(listen_to_done_event(+ reader_description,+ tracking,+ done_event,+ ))))+ } Some(InProgressState::InProgress(box InProgressStateInner { done_event, ..- })) => Some(Ok(Err(listen_to_done_event(+ })) => Some(Ok(ReadOutcome::InProgress(listen_to_done_event( reader_description, tracking, done_event,@@ -704,7 +710,7 @@ impl TurboTasksBackend { queue.execute(&mut ctx); } - return Ok(Err(listener));+ return Ok(ReadOutcome::InProgress(listener)); } } @@ -723,8 +729,8 @@ impl TurboTasksBackend { if let Some(output) = task.get_output() { let result = match output {- OutputValue::Cell(cell) => Ok(Ok(RawVc::task_cell(cell.task, cell.cell))),- OutputValue::Output(task) => Ok(Ok(RawVc::task_output(*task))),+ OutputValue::Cell(cell) => Ok(RawVc::task_cell(cell.task, cell.cell)),+ OutputValue::Output(task) => Ok(RawVc::task_output(*task)), OutputValue::Error(error) => Err(error.clone()), }; if let Some(mut reader_task) = reader_task.take()@@ -770,7 +776,7 @@ impl TurboTasksBackend { drop(task); } - return result.map_err(|error| {+ return result.map(ReadOutcome::Value).map_err(|error| { self.task_error_to_turbo_tasks_execution_error(&error, &mut ctx) .with_task_context(task_id, turbo_tasks.pin()) .into()@@ -801,7 +807,7 @@ impl TurboTasksBackend { debug_assert!(old.is_none(), "InProgress already exists"); ctx.schedule_task(task, TaskPriority::Recomputation); - Ok(Err(listener))+ Ok(ReadOutcome::Scheduled(listener)) } fn try_read_task_cell(@@ -811,7 +817,7 @@ impl TurboTasksBackend { cell: CellId, options: ReadCellOptions, turbo_tasks: &TurboTasks<TurboTasksBackend>,- ) -> Result<Result<TypedCellContent, EventListener>> {+ ) -> Result<ReadOutcome<TypedCellContent>> { self.assert_not_persistent_calling_transient(reader, task_id, Some(cell)); fn add_cell_dependency(@@ -876,7 +882,7 @@ impl TurboTasksBackend { if tracking.should_track(false) { add_cell_dependency(task_id, task, reader, reader_task, cell, tracking.key()); }- return Ok(Ok(TypedCellContent(+ return Ok(ReadOutcome::Value(TypedCellContent( cell.type_id(), CellContent(Some(content)), )));@@ -887,9 +893,17 @@ impl TurboTasksBackend { in_progress, Some(InProgressState::InProgress(..) | InProgressState::Scheduled { .. }) ) {- return Ok(Err(self+ // Tell the reader whether the task is merely queued (it may take it over and execute it+ // itself) or already being executed by a worker (it can only wait).+ let started = matches!(in_progress, Some(InProgressState::InProgress(..)));+ let listener = self .listen_to_cell(&mut task, task_id, need_reader_task, &reader_task, cell)- .0));+ .0;+ return Ok(if started {+ ReadOutcome::InProgress(listener)+ } else {+ ReadOutcome::Scheduled(listener)+ }); } let is_cancelled = matches!(in_progress, Some(InProgressState::Canceled)); @@ -926,7 +940,8 @@ impl TurboTasksBackend { self.listen_to_cell(&mut task, task_id, need_reader_task, &reader_task, cell); drop(reader_task); if !new_listener {- return Ok(Err(listener));+ // Somebody else is already waiting for this cell, so the task is being taken care of.+ return Ok(ReadOutcome::InProgress(listener)); } let _span = tracing::trace_span!(@@ -942,7 +957,7 @@ impl TurboTasksBackend { ); ctx.schedule_task(task, TaskPriority::Recomputation); - Ok(Err(listener))+ Ok(ReadOutcome::Scheduled(listener)) } fn listen_to_cell(@@ -1982,7 +1997,12 @@ impl TurboTasksBackend { ) } TaskType::Transient(task_type) => {- let span = tracing::trace_span!("turbo_tasks::root_task");+ // `inline_execution` is recorded when a read executed this task on its own thread,+ // see `NativeFunction::span`.+ let span = tracing::trace_span!(+ "turbo_tasks::root_task",+ inline_execution = tracing::field::Empty+ ); let future = match &*task_type { TransientTask::Root(f) => f(), TransientTask::Once(future_mutex) => take(&mut *future_mutex.lock())?,@@ -3662,7 +3682,7 @@ impl Backend for TurboTasksBackend { reader: Option<TaskId>, options: ReadOutputOptions, turbo_tasks: &TurboTasks<Self>,- ) -> Result<Result<RawVc, EventListener>> {+ ) -> Result<ReadOutcome<RawVc>> { self.try_read_task_output(task_id, reader, options, turbo_tasks) } @@ -3673,7 +3693,7 @@ impl Backend for TurboTasksBackend { reader: Option<TaskId>, options: ReadCellOptions, turbo_tasks: &TurboTasks<Self>,- ) -> Result<Result<TypedCellContent, EventListener>> {+ ) -> Result<ReadOutcome<TypedCellContent>> { self.try_read_task_cell(task_id, reader, cell, options, turbo_tasks) } turbopack/crates/turbo-tasks-backend/src/backend/operation/mod.rs18 + / 12 −
@@ -796,6 +796,22 @@ fn restored_from_disk(result: &Option<Result<Option<TaskStorage>>>) -> bool { matches!(result, Some(Ok(Some(_)))) } +/// The priority a task is scheduled with: an already computed task is a re-computation of a+/// (possibly deep) dependency, everything else starts at the initial priority.+fn schedule_priority(task: &impl TaskGuard, parent_priority: TaskPriority) -> TaskPriority {+ let priority = if task.has_output() {+ TaskPriority::invalidation(+ task.get_leaf_distance()+ .copied()+ .unwrap_or_default()+ .distance,+ )+ } else {+ TaskPriority::initial()+ };+ priority.in_parent(parent_priority)+}+ /// Combines per-category booleans into a single `TaskDataCategory` for waiting. fn wait_category(wait_data: bool, wait_meta: bool) -> Option<TaskDataCategory> { match (wait_data, wait_meta) {@@ -1095,18 +1111,8 @@ impl<'e> ExecuteContext<'e> for ExecuteContextImpl<'e> { } fn schedule_task(&self, task: Self::TaskGuardImpl, parent_priority: TaskPriority) {- let priority = if task.has_output() {- TaskPriority::invalidation(- task.get_leaf_distance()- .copied()- .unwrap_or_default()- .distance,- )- } else {- TaskPriority::initial()- };- self.turbo_tasks- .schedule(task.id(), priority.in_parent(parent_priority));+ let priority = schedule_priority(&task, parent_priority);+ self.turbo_tasks.schedule(task.id(), priority); } fn get_current_task_priority(&self) -> TaskPriority {turbopack/crates/turbo-tasks-backend/tests/inline_read_execution.rsadded411 + / 0 −
@@ -0,0 +1,411 @@+#![feature(arbitrary_self_types)]+#![feature(arbitrary_self_types_pointers)]+#![allow(clippy::needless_return)] // tokio macro-generated code doesn't respect this++//! Reading a task that is scheduled but not started yet must execute that task *inline* on the+//! reading thread, so the read can complete without ever returning `Poll::Pending`.+//!+//! Most tests run on a single tokio worker on purpose: with `worker_threads = 1` the+//! `PriorityRunner`'s target worker count is 1, so a task scheduled from inside another task's+//! execution is always queued (never immediately spawned). That makes "the task is still in the+//! priority runner when it is read" deterministic. The tests that are about contention+//! (`test_read_of_running_task_is_not_executed_again`, `test_read_outcome_counters`) use more.++use std::{+ future::{Future, IntoFuture},+ pin::pin,+ sync::atomic::{AtomicUsize, Ordering},+ task::{Context, Poll, Waker},+};++use anyhow::Result;+use turbo_tasks::{ReadRef, ResolvedVc, State, Vc};+use turbo_tasks_testing::{Registration, register, run_once};++static REGISTRATION: Registration = register!();++/// Polls `fut` exactly once with a non-waking waker.+fn poll_once<F: Future>(fut: F) -> Poll<F::Output> {+ let mut fut = pin!(fut);+ let waker = Waker::noop();+ let mut cx = Context::from_waker(waker);+ fut.as_mut().poll(&mut cx)+}++/// A read of a not-yet-started task completes in a single poll, because the reader picks the+/// task up from the priority runner and executes it inline.+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]+async fn test_read_of_scheduled_task_is_inline() {+ let mut nonce = 0;+ run_once(®ISTRATION, move || {+ nonce += 1;+ async move {+ read_scheduled_task_inline(nonce)+ .read_strongly_consistent()+ .await+ }+ })+ .await+ .unwrap();+}++#[turbo_tasks::function(operation, root)]+async fn read_scheduled_task_inline(nonce: u32) -> Result<Vc<()>> {+ // `leaf` has never been computed and completes without awaiting anything, so the read must+ // resolve in the very first poll.+ let leaf_vc = leaf(nonce);+ let Poll::Ready(result) = poll_once(leaf_vc.into_future()) else {+ panic!("reading a scheduled-but-not-started task did not complete inline");+ };+ assert_eq!(result?.value, 42);++ // A second read hits the cache and obviously stays inline.+ let Poll::Ready(result) = poll_once(leaf(nonce).into_future()) else {+ panic!("reading a completed task did not complete inline");+ };+ assert_eq!(result?.value, 42);++ Ok(Vc::cell(()))+}++/// When the inline execution yields, the read parks as usual and the execution is completed+/// elsewhere — the value must still arrive.+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]+async fn test_read_of_yielding_task_still_completes() {+ let mut nonce = 0;+ read_yielding_task(&mut nonce).await;+}++async fn read_yielding_task(nonce: &mut u32) {+ run_once(®ISTRATION, {+ *nonce += 1;+ let nonce = *nonce;+ move || async move {+ read_yielding_task_operation(nonce)+ .read_strongly_consistent()+ .await+ }+ })+ .await+ .unwrap();+}++#[turbo_tasks::function(operation, root)]+async fn read_yielding_task_operation(nonce: u32) -> Result<Vc<()>> {+ // `yielding_leaf` yields during its execution, so the inline poll cannot finish it. The read+ // has to park — and the value must still be produced.+ let vc = yielding_leaf(nonce);+ assert!(+ poll_once(vc.into_future()).is_pending(),+ "a task that yields must not resolve in the first poll"+ );+ assert_eq!(vc.await?.value, 7);++ Ok(Vc::cell(()))+}++/// A call with unresolved arguments creates a *local* task. Reading its output must execute the+/// local task (and the global task it resolves to) inline as well.+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]+async fn test_read_of_local_task_is_inline() {+ let mut nonce = 0;+ run_once(®ISTRATION, move || {+ nonce += 1;+ async move {+ read_local_task_inline(nonce)+ .read_strongly_consistent()+ .await+ }+ })+ .await+ .unwrap();+}++#[turbo_tasks::function(operation, root)]+async fn read_local_task_inline(nonce: u32) -> Result<Vc<()>> {+ // An unresolved `Vc` argument: `identity` is called with the not-yet-resolved output of+ // `leaf`, which creates a local resolve task.+ let leaf_vc = leaf(nonce);+ assert_eq!(leaf_vc.await?.value, 42);++ let local_vc = identity(leaf_vc);+ let Poll::Ready(result) = poll_once(local_vc.into_future()) else {+ panic!("reading a scheduled local task did not complete inline");+ };+ assert_eq!(result?.value, 42);++ Ok(Vc::cell(()))+}++/// Reports how reads and inline execution interacted, and checks the invariants of the counters.+///+/// Deliberately does *not* assert a particular mix: what a read finds depends on how loaded the+/// scheduler is. On a small, idle instance like this one, `connect_child` schedules each fresh task+/// eagerly and the worker it spawns wins the race against the reader, so claims mostly fail; in a+/// saturated build the tasks stay queued and the reader takes them over instead. Both are correct,+/// and the printed histogram is the point of this test.+///+/// In particular `waited_in_progress` cannot be asserted here: a worker pops a task from the queue+/// *before* `try_start_task_execution` flips the state to `InProgress`, so a read that lands in+/// that window still sees `Scheduled` and attempts a claim that cannot succeed. Whether a read sees+/// `Scheduled` or `InProgress` is therefore a matter of timing by design — it is a hint, and acting+/// on a stale one only costs a failed claim.+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]+async fn test_read_outcome_counters() {+ let tt = create_test_turbo_tasks("test_read_outcome_counters", true);++ let before = tt.inline_execution_stats();+ turbo_tasks::run_once(tt.clone(), async move {+ read_many_leaves(1, 21).read_strongly_consistent().await?;+ Ok(())+ })+ .await+ .unwrap();+ let after = tt.inline_execution_stats();++ println!("stats before: {before:#?}\nstats after: {after:#?}");+ assert!(+ after.claim_attempted > before.claim_attempted,+ "reads that find a task queued must try to take it over"+ );+ assert_eq!(+ after.claim_attempted - before.claim_attempted,+ (after.claim_completed - before.claim_completed)+ + (after.claim_yielded - before.claim_yielded)+ + (after.claim_failed - before.claim_failed),+ "every claim attempt has exactly one outcome"+ );+}++/// A task that is invalidated *while it is being executed* is stale and gets scheduled again, so a+/// read can go around the retry more than once. It has to terminate, and it must not grow the+/// native stack per retry (which is why the retry is a loop and not recursion).+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]+async fn test_stale_during_execution_terminates() {+ let result = tokio::time::timeout(std::time::Duration::from_secs(60), async {+ run_once(®ISTRATION, || async {+ let input = ReadRef::resolved_cell(ReadRef::new_owned(ChangingInput {+ state: State::new(0),+ }));+ let output = self_invalidating(input);+ // The task bumps the state it depends on the first few times it runs, invalidating+ // itself, so this read has to survive several rescheduled executions.+ assert!(output.read_strongly_consistent().await?.value >= 3);+ Ok(())+ })+ .await+ .unwrap();+ })+ .await;+ assert!(+ result.is_ok(),+ "a task that keeps invalidating itself must still settle"+ );+}++#[turbo_tasks::value]+struct ChangingInput {+ state: State<u32>,+}++#[turbo_tasks::function(operation, root)]+async fn self_invalidating(input: ResolvedVc<ChangingInput>) -> Result<Vc<Value>> {+ let value = *input.await?.state.get();+ if value < 3 {+ // Invalidate ourselves: the execution that is running right now becomes stale and is+ // scheduled again.+ input.await?.state.set(value + 1);+ }+ Ok(Value { value: value + 1 }.cell())+}++/// Restoring from a persistent cache in a fresh instance — what an incremental build does — must+/// recompute what it cannot reuse, produce the right values, and not panic.+///+/// Worth keeping even though it looks mundane: reads behave differently here than in a cold build+/// (tasks come back dirty and are recomputed rather than found in the queue), and every other test+/// in this file — like every cold build — never exercises that.+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]+async fn test_restore_from_persistent_cache_recomputes_and_does_not_panic() {+ let name = "test_restore_from_persistent_cache_recomputes";++ // First instance: compute the tasks and flush them to the persistent cache.+ let first = create_test_turbo_tasks(name, true);+ turbo_tasks::run_once(first.clone(), async move {+ read_session_leaves(21).read_strongly_consistent().await?;+ Ok(())+ })+ .await+ .unwrap();+ first.stop_and_wait().await;++ // Second instance on the same cache: the session-dependent results cannot be reused, so they+ // have to be recomputed.+ let second = create_test_turbo_tasks(name, false);+ let before = second.inline_execution_stats();+ turbo_tasks::run_once(second.clone(), async move {+ read_session_leaves(21).read_strongly_consistent().await?;+ Ok(())+ })+ .await+ .unwrap();+ let after = second.inline_execution_stats();+ second.stop_and_wait().await;++ println!("stats before: {before:#?}\nstats after: {after:#?}");+ assert!(+ after.queued > before.queued,+ "the session-dependent tasks must be recomputed after the restore"+ );+}++/// Builds a `TurboTasks` on a per-name persistence directory. `initial` wipes that directory first;+/// passing `false` reuses what a previous instance flushed, which is how an incremental build+/// starts up.+fn create_test_turbo_tasks(+ name: &str,+ initial: bool,+) -> std::sync::Arc<turbo_tasks::TurboTasks<turbo_tasks_backend::TurboTasksBackend>> {+ let inner = include!(concat!(+ env!("CARGO_MANIFEST_DIR"),+ "/tests/test_config.trs"+ ));+ (inner)(name, initial)+}++#[turbo_tasks::function(operation, root)]+async fn read_session_leaves(leaves: u32) -> Result<Vc<()>> {+ for i in 0..leaves {+ assert_eq!(session_leaf(i).await?.value, i + 1);+ }+ Ok(Vc::cell(()))+}++/// Session-dependent, so its result is not reused across `TurboTasks` instances: a second instance+/// restoring from the same persistent cache has to recompute it, and the read is what schedules it.+#[turbo_tasks::function(session_dependent)]+async fn session_leaf(index: u32) -> Result<Vc<Value>> {+ Ok(Value { value: index + 1 }.cell())+}++#[turbo_tasks::function(operation, root)]+async fn read_many_leaves(nonce: u32, leaves: u32) -> Result<Vc<()>> {+ for i in 0..leaves {+ // Every leaf is a distinct, never-computed task.+ assert_eq!(leaf(nonce * 1000 + i).await?.value, 42);+ }+ Ok(Vc::cell(()))+}++#[turbo_tasks::value]+#[derive(Clone, Debug)]+struct Value {+ value: u32,+}++/// A task whose execution is already in progress is not executed a second time, and reading it+/// works as it always did.+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]+async fn test_read_of_running_task_is_not_executed_again() {+ let mut nonce = 0;+ run_once(®ISTRATION, move || {+ nonce += 1;+ async move {+ read_running_task(nonce)+ .read_strongly_consistent()+ .await+ .map(|_| ())+ }+ })+ .await+ .unwrap();+ assert_eq!(+ COUNTED_EXECUTIONS.load(Ordering::SeqCst),+ 1,+ "the task must be executed exactly once, no matter how many readers race for it"+ );+}++/// Only `read_running_task` may use this: it is process-global, and that operation resets it at the+/// start of each execution.+static COUNTED_EXECUTIONS: AtomicUsize = AtomicUsize::new(0);++#[turbo_tasks::function(operation, root)]+async fn read_running_task(nonce: u32) -> Result<Vc<()>> {+ COUNTED_EXECUTIONS.store(0, Ordering::SeqCst);+ // Eight readers of the same task, spread over the worker threads: at most one of them can+ // execute it (inline or on a worker), the others have to wait for it.+ let mut values = Vec::new();+ for _ in 0..8 {+ values.push(counted_leaf(nonce));+ }+ for value in values {+ assert_eq!(value.await?.value, 5);+ }+ Ok(Vc::cell(()))+}++#[turbo_tasks::function]+async fn counted_leaf(nonce: u32) -> Result<Vc<Value>> {+ // Keeps `nonce` in the cache key: `#[turbo_tasks::function]` filters out arguments the body+ // never uses, and then every run would reuse the first run's cached value.+ let _ = nonce;+ COUNTED_EXECUTIONS.fetch_add(1, Ordering::SeqCst);+ // Yield so the other readers reach the task while it is in progress.+ tokio::task::yield_now().await;+ Ok(Value { value: 5 }.cell())+}++/// Inline execution nests: reading the deepest task of a chain of uncomputed tasks executes them+/// one inside the other. The nesting cap keeps that from growing the stack without bounds.+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]+async fn test_deep_dependency_chain() {+ let mut nonce = 0;+ run_once(®ISTRATION, move || {+ nonce += 1;+ async move {+ deep_chain_operation(nonce)+ .read_strongly_consistent()+ .await+ .map(|_| ())+ }+ })+ .await+ .unwrap();+}++#[turbo_tasks::function(operation, root)]+async fn deep_chain_operation(nonce: u32) -> Result<Vc<()>> {+ // Reading the top of the chain requires computing all 500 links, each of which reads the next+ // one as the first thing it does.+ assert_eq!(chain_link(nonce, 500).await?.value, 500);+ Ok(Vc::cell(()))+}++#[turbo_tasks::function]+async fn chain_link(nonce: u32, depth: u32) -> Result<Vc<Value>> {+ if depth == 0 {+ return Ok(Value { value: 0 }.cell());+ }+ let inner = chain_link(nonce, depth - 1).await?.value;+ Ok(Value { value: inner + 1 }.cell())+}++#[turbo_tasks::function]+fn leaf(nonce: u32) -> Result<Vc<Value>> {+ let _ = nonce; // keeps `nonce` in the cache key, see `counted_leaf`+ Ok(Value { value: 42 }.cell())+}++#[turbo_tasks::function]+async fn yielding_leaf(nonce: u32) -> Result<Vc<Value>> {+ let _ = nonce; // keeps `nonce` in the cache key, see `counted_leaf`+ tokio::task::yield_now().await;+ Ok(Value { value: 7 }.cell())+}++#[turbo_tasks::function]+async fn identity(input: Vc<Value>) -> Result<Vc<Value>> {+ let value = input.await?.value;+ Ok(Value { value }.cell())+}turbopack/crates/turbo-tasks/Cargo.toml8 + / 0 −
@@ -15,6 +15,14 @@ hanging_detection = [] task_id_details = [] task_dirty_cause = [] verify_determinism = []+# Counts what reads do when they can't get a value straight away: queue pushes, claim attempts and+# their outcomes, and waits for a task a worker is already running. Reported by+# `TurboTasks::inline_execution_stats()`, and printed on shutdown with+# `TURBO_ENGINE_INLINE_STATS=1`.+#+# Off by default: the counters sit on the read-miss path, so a build that doesn't want the numbers+# shouldn't pay for them. With the feature off there are no counters and no atomics at all.+inline_execution_stats = [] # TODO(bgw): This feature is here to unblock turning on local tasks by default. It's only turned on # in unit tests. This will be removed very soon.turbopack/crates/turbo-tasks/src/backend.rs5 + / 6 −
@@ -29,11 +29,10 @@ use turbo_rcstr::RcStr; use turbo_tasks_hash::DeterministicHasher; use crate::{- CellId, RawVc, ReadCellOptions, ReadOutputOptions, ReadRef, SharedReference, TaskId, TaskIdSet,- TaskPriority, TraitRef, TraitTypeId, TurboTasksCallApi, TurboTasksPanic, ValueTypeId,- ValueTypePersistence, VcValueTrait, VcValueType,+ CellId, RawVc, ReadCellOptions, ReadOutcome, ReadOutputOptions, ReadRef, SharedReference,+ TaskId, TaskIdSet, TaskPriority, TraitRef, TraitTypeId, TurboTasksCallApi, TurboTasksPanic,+ ValueTypeId, ValueTypePersistence, VcValueTrait, VcValueType, dyn_task_inputs::{DynTaskInputs, DynTaskInputsStorage},- event::EventListener, macro_helpers::NativeFunction, manager::{TaskPersistence, TurboTasks}, registry,@@ -654,7 +653,7 @@ pub trait Backend: Sized + Sync + Send { reader: Option<TaskId>, options: ReadOutputOptions, turbo_tasks: &TurboTasks<Self>,- ) -> Result<Result<RawVc, EventListener>>;+ ) -> Result<ReadOutcome<RawVc>>; /// INVALIDATION: Be careful with this, when reader is None, it will not track dependencies, so /// using it could break cache invalidation.@@ -665,7 +664,7 @@ pub trait Backend: Sized + Sync + Send { reader: Option<TaskId>, options: ReadCellOptions, turbo_tasks: &TurboTasks<Self>,- ) -> Result<Result<TypedCellContent, EventListener>>;+ ) -> Result<ReadOutcome<TypedCellContent>>; /// INVALIDATION: Be careful with this, it will not track dependencies, so /// using it could break cache invalidation.turbopack/crates/turbo-tasks/src/lib.rs5 + / 3 −
@@ -73,6 +73,8 @@ use rustc_hash::FxHasher; pub use shrink_to_fit::ShrinkToFit; pub use turbo_tasks_macros::{DeterministicHash, turbobail, turbofmt}; +#[cfg(feature = "inline_execution_stats")]+pub use crate::manager::InlineExecutionStats; #[cfg(feature = "task_dirty_cause")] pub use crate::task_dirty_cause::TaskDirtyCause; pub use crate::{@@ -99,15 +101,15 @@ pub use crate::{ join_iter_ext::{JoinIterExt, TryFlatJoinIterExt, TryJoinIterExt}, manager::{ CurrentCellRef, InputResolution, ReadCellTracking, ReadConsistency, ReadTracking,- TaskPersistence, TaskPriority, TurboTasks, TurboTasksApi, TurboTasksCallApi, Unused,- UpdateInfo, dynamic_call, emit, get_serialization_invalidator, mark_finished,+ ScheduleKey, TaskPersistence, TaskPriority, TurboTasks, TurboTasksApi, TurboTasksCallApi,+ Unused, UpdateInfo, dynamic_call, emit, get_serialization_invalidator, mark_finished, mark_stateful, mark_top_level_task, prevent_gc, run, run_once, run_once_with_reason, trait_call, turbo_tasks, turbo_tasks_scope, turbo_tasks_weak, unmark_top_level_task_may_leak_eventually_consistent_state, with_turbo_tasks, }, mapped_read_ref::MappedReadRef, output::OutputContent,- read_options::{ReadCellOptions, ReadOutputOptions},+ read_options::{ReadCellOptions, ReadOutcome, ReadOutputOptions}, read_ref::ReadRef, serialization_invalidation::SerializationInvalidator, spawn::{JoinHandle, block_for_future, block_in_place, spawn, spawn_blocking, spawn_thread},turbopack/crates/turbo-tasks/src/manager.rs410 + / 29 −
@@ -1,4 +1,5 @@ use std::{+ cell::Cell, cmp::Reverse, fmt::{Debug, Display}, future::Future,@@ -12,6 +13,7 @@ use std::{ Arc, Mutex, RwLock, Weak, atomic::{AtomicBool, AtomicUsize, Ordering}, },+ task::{Context, Poll, Waker}, time::{Duration, Instant}, }; @@ -29,8 +31,8 @@ use turbo_tasks_hash::{DeterministicHash, hash_xxh3_hash128}; use crate::{ CellId, Completion, InvalidationReason, InvalidationReasonSet, OutputContent, RawVc,- ReadCellOptions, ReadOutputOptions, ResolvedVc, SharedReference, TaskId, TraitMethod,- ValueTypeId, Vc, VcRead, VcValueTrait, VcValueType,+ ReadCellOptions, ReadOutcome, ReadOutputOptions, ResolvedVc, SharedReference, TaskId,+ TraitMethod, ValueTypeId, Vc, VcRead, VcValueTrait, VcValueType, backend::{ Backend, CellContent, CellHash, TaskCollectiblesMap, TaskExecutionSpec, TransientTaskType, TurboTasksExecutionError, TypedCellContent, VerificationMode,@@ -43,7 +45,7 @@ use crate::{ local_task_tracker::LocalTaskTracker, macro_helpers::NativeFunction, message_queue::{CompilationEvent, CompilationEventQueue},- priority_runner::{Executor, PriorityRunner},+ priority_runner::{Claimable, Executor, PriorityRunner}, registry, serialization_invalidation::SerializationInvalidator, task::local_task::{LocalTask, LocalTaskSpec, LocalTaskType},@@ -130,14 +132,14 @@ pub trait TurboTasksApi: TurboTasksCallApi + Sync + Send { &self, task: TaskId, options: ReadOutputOptions,- ) -> Result<Result<RawVc, EventListener>>;+ ) -> Result<ReadOutcome<RawVc>>; fn try_read_task_cell( &self, task: TaskId, index: CellId, options: ReadCellOptions,- ) -> Result<Result<TypedCellContent, EventListener>>;+ ) -> Result<ReadOutcome<TypedCellContent>>; /// Reads a [`RawVc::LocalOutput`]. If the task has completed, returns the [`RawVc`] the local /// task points to.@@ -161,6 +163,18 @@ pub trait TurboTasksApi: TurboTasksCallApi + Sync + Send { fn read_task_collectibles(&self, task: TaskId, trait_id: TraitTypeId) -> TaskCollectiblesMap; + /// Executes a task that is scheduled but not started yet inline on the current thread, so that+ /// a read doesn't have to wait for a worker to pick the task up. Returns whether the task's+ /// execution completed.+ ///+ /// Used by the read paths; see `TurboTasks::try_execute_scheduled_task_inline` for the details.+ fn try_execute_scheduled_task_inline(&self, key: ScheduleKey) -> bool;++ /// Records that a read waited for a task that a worker was already executing, so it did not try+ /// to claim it. Diagnostics only, see `TurboTasks::inline_execution_stats`.+ #[cfg(feature = "inline_execution_stats")]+ fn note_waited_for_in_progress_task(&self);+ fn emit_collectible(&self, trait_type: TraitTypeId, collectible: RawVc); fn unemit_collectible(&self, trait_type: TraitTypeId, collectible: RawVc, count: u32); fn unemit_collectibles(&self, trait_type: TraitTypeId, collectibles: &TaskCollectiblesMap);@@ -465,12 +479,193 @@ enum ScheduledTask { LocalTask { ty: LocalTaskSpec, persistence: TaskPersistence,+ execution_id: ExecutionId, local_task_id: LocalTaskId, global_task_state: CurrentTaskStateHandle, span: Span, }, } +/// Identifies a scheduled task, so that a read which is about to wait for it can take it out of the+/// scheduler queue and execute it inline instead (see `PriorityRunner::claim` and+/// `execute_read_target_inline`).+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]+pub enum ScheduleKey {+ /// A cached (non-local) task.+ Task(TaskId),+ /// A local task, which is only known within the execution that created it.+ LocalTask(ExecutionId, LocalTaskId),+}++impl Claimable for ScheduledTask {+ type Key = ScheduleKey;++ fn claim_key(&self) -> Option<ScheduleKey> {+ Some(match self {+ ScheduledTask::Task { task_id, .. } => ScheduleKey::Task(*task_id),+ ScheduledTask::LocalTask {+ execution_id,+ local_task_id,+ ..+ } => ScheduleKey::LocalTask(*execution_id, *local_task_id),+ })+ }+}++#[cfg(feature = "inline_execution_stats")]+use std::sync::atomic::AtomicU64;++/// Counters describing how reads and inline execution interacted, see+/// [`TurboTasks::inline_execution_stats`]. Diagnostics only.+#[cfg(feature = "inline_execution_stats")]+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]+pub struct InlineExecutionStats {+ /// Tasks that were put into the scheduler queue.+ pub queued: u64,+ /// Reads that tried to take a queued task out of the queue.+ pub claim_attempted: u64,+ /// Claims that succeeded and whose execution finished on the reading thread.+ pub claim_completed: u64,+ /// Claims that succeeded but whose execution yielded, so it was handed to the runtime.+ pub claim_yielded: u64,+ /// Claims that found nothing to take, because a worker had already picked the task up.+ pub claim_failed: u64,+ /// Reads that waited without attempting a claim, because the task was already being executed.+ pub waited_in_progress: u64,+}++/// The counters behind [`InlineExecutionStats`].+///+/// Without the `inline_execution_stats` feature this is zero-sized and every method is an empty+/// `#[inline]` no-op, so the counting compiles away: the counters sit on the read-miss path, and a+/// build that doesn't want the numbers shouldn't pay for them.+#[derive(Default)]+struct InlineExecutionCounters {+ #[cfg(feature = "inline_execution_stats")]+ claim_attempted: AtomicU64,+ #[cfg(feature = "inline_execution_stats")]+ claim_completed: AtomicU64,+ #[cfg(feature = "inline_execution_stats")]+ claim_yielded: AtomicU64,+ #[cfg(feature = "inline_execution_stats")]+ claim_failed: AtomicU64,+ #[cfg(feature = "inline_execution_stats")]+ waited_in_progress: AtomicU64,+}++impl InlineExecutionCounters {+ /// A read found its task queued and tried to take it over.+ #[inline]+ fn claim_attempted(&self) {+ #[cfg(feature = "inline_execution_stats")]+ self.claim_attempted.fetch_add(1, Ordering::Relaxed);+ }++ /// A claim succeeded and the execution finished on the reading thread.+ #[inline]+ fn claim_completed(&self) {+ #[cfg(feature = "inline_execution_stats")]+ self.claim_completed.fetch_add(1, Ordering::Relaxed);+ }++ /// A claim succeeded but the execution yielded, so it was handed to the runtime.+ #[inline]+ fn claim_yielded(&self) {+ #[cfg(feature = "inline_execution_stats")]+ self.claim_yielded.fetch_add(1, Ordering::Relaxed);+ }++ /// A claim found nothing to take, because a worker had already picked the task up.+ #[inline]+ fn claim_failed(&self) {+ #[cfg(feature = "inline_execution_stats")]+ self.claim_failed.fetch_add(1, Ordering::Relaxed);+ }++ /// A read waited without attempting a claim, because the task was already being executed.+ #[cfg(feature = "inline_execution_stats")]+ #[inline]+ fn waited_in_progress(&self) {+ self.waited_in_progress.fetch_add(1, Ordering::Relaxed);+ }+}++/// Whether a dump of [`InlineExecutionStats`] was requested via `TURBO_ENGINE_INLINE_STATS=1`.+#[cfg(feature = "inline_execution_stats")]+pub(crate) fn inline_stats_requested() -> bool {+ static REQUESTED: std::sync::LazyLock<bool> = std::sync::LazyLock::new(|| {+ std::env::var("TURBO_ENGINE_INLINE_STATS").is_ok_and(|value| value != "0")+ });+ *REQUESTED+}++/// Maximum number of task executions that may be nested inline on a single thread, to conserve+/// stack space. (The alternative would be growing the stack on demand, the way SWC does.)+const MAX_INLINE_EXECUTION_DEPTH: usize = 16;++thread_local! {+ /// How many task executions are currently nested inline on this thread.+ static INLINE_EXECUTION_DEPTH: Cell<usize> = const { Cell::new(0) };+}++/// Whether the current thread may execute another task inline, see [`MAX_INLINE_EXECUTION_DEPTH`].+fn inline_execution_allowed() -> bool {+ INLINE_EXECUTION_DEPTH.get() < MAX_INLINE_EXECUTION_DEPTH+}++/// Counts one level of inline task execution on this thread, see [`MAX_INLINE_EXECUTION_DEPTH`].+struct InlineExecutionDepthGuard;++impl InlineExecutionDepthGuard {+ fn enter() -> Self {+ INLINE_EXECUTION_DEPTH.set(INLINE_EXECUTION_DEPTH.get() + 1);+ Self+ }+}++impl Drop for InlineExecutionDepthGuard {+ fn drop(&mut self) {+ INLINE_EXECUTION_DEPTH.set(INLINE_EXECUTION_DEPTH.get() - 1);+ }+}++/// Polls `future` once inline and then spawns it if it doesn't complete so tokio drives it. Returns+/// whether it completed.+fn poll_once_or_spawn(future: impl Future<Output = ()> + Send + 'static) -> bool {+ let _depth_guard = InlineExecutionDepthGuard::enter();+ let span_slot = InlineExecutionSpanSlot::default();+ let mut future = Box::pin(INLINE_EXECUTION_SPAN.scope(span_slot.clone(), future));+ // A waker that never wakes anything is fine here: if this poll doesn't complete the future we+ // spawn it, and a spawned task is always polled at least once, which is the poll that registers+ // the real waker.+ match future+ .as_mut()+ .poll(&mut Context::from_waker(Waker::noop()))+ {+ Poll::Ready(()) => {+ span_slot.record("complete");+ true+ }+ Poll::Pending => {+ span_slot.record("partial");+ tokio::task::spawn(future);+ false+ }+ }+}++/// Executes the task inline if possible, returns true if it executed to completion.+pub(crate) fn execute_read_target_inline(+ turbo_tasks: &dyn TurboTasksApi,+ key: ScheduleKey,+) -> bool {+ if !inline_execution_allowed() {+ // Nested too deeply; a worker will pick the task up, as it always did.+ return false;+ }+ turbo_tasks.try_execute_scheduled_task_inline(key)+}+ pub struct TurboTasks<B: Backend + 'static> { this: Weak<Self>, backend: B,@@ -479,6 +674,9 @@ pub struct TurboTasks<B: Backend + 'static> { currently_scheduled_foreground_jobs: AtomicUsize, currently_scheduled_background_jobs: AtomicUsize, scheduled_tasks: AtomicUsize,+ /// Diagnostics for reads and inline execution, see `TurboTasks::inline_execution_stats`.+ /// Zero-sized without the `inline_execution_stats` feature.+ inline_counters: InlineExecutionCounters, priority_runner: Arc<PriorityRunner<TurboTasks<B>, ScheduledTask, TaskPriority, TurboTasksExecutor>>, start: Mutex<Option<Instant>>,@@ -625,6 +823,41 @@ task_local! { /// This is NOT shared across local tasks (unlike CURRENT_TASK_STATE), so it's safe /// to set/unset without race conditions. pub(crate) static SUPPRESS_EVENTUAL_CONSISTENCY_TOP_LEVEL_TASK_CHECK: bool;++ /// Set only while a reader polls a task it claimed, so that the outcome of that poll can be+ /// recorded on the *task's* span rather than the reader's, see [`InlineExecutionSpanSlot`].+ static INLINE_EXECUTION_SPAN: InlineExecutionSpanSlot;+}++/// Lets a reader that executes a claimed task inline record the outcome on the span of the task it+/// executed.+///+/// The reader only sees the outer execution future, whose instrumented span has already been exited+/// by the time its `poll` returns — `Span::current()` there is the reader's own span. So the+/// executor puts the span it is about to instrument the task body with in here, and the reader+/// records the outcome on it afterwards.+///+/// Only present while a claimed task is being polled inline: a task started by a worker doesn't+/// have this task-local set, and leaves the field unset.+#[derive(Clone, Default)]+struct InlineExecutionSpanSlot(Arc<Mutex<Option<Span>>>);++impl InlineExecutionSpanSlot {+ /// Called by the executor with the span it instruments the task body with.+ fn set(span: &Span) {+ let _ = INLINE_EXECUTION_SPAN.try_with(|slot| {+ *slot.0.lock().unwrap() = Some(span.clone());+ });+ }++ /// Records the outcome of the inline poll on the executed task's span, if the executor got far+ /// enough to register one (it doesn't when the task was already claimed by someone else and the+ /// execution turns into a no-op).+ fn record(&self, outcome: &'static str) {+ if let Some(span) = self.0.lock().unwrap().as_ref() {+ span.record("inline_execution", outcome);+ }+ } } impl<B: Backend + 'static> TurboTasks<B> {@@ -643,6 +876,7 @@ impl<B: Backend + 'static> TurboTasks<B> { currently_scheduled_foreground_jobs: AtomicUsize::new(0), currently_scheduled_background_jobs: AtomicUsize::new(0), scheduled_tasks: AtomicUsize::new(0),+ inline_counters: InlineExecutionCounters::default(), priority_runner: Arc::new(PriorityRunner::new(TurboTasksExecutor)), start: Default::default(), aggregated_update: Default::default(),@@ -871,14 +1105,11 @@ impl<B: Backend + 'static> TurboTasks<B> { self.begin_foreground_job(); self.scheduled_tasks.fetch_add(1, Ordering::AcqRel); - self.priority_runner.schedule(- &self.pin(),- ScheduledTask::Task {- task_id,- span: Span::current(),- },- priority,- );+ let task = ScheduledTask::Task {+ task_id,+ span: Span::current(),+ };+ self.priority_runner.schedule(&self.pin(), task, priority); } fn schedule_local_task(@@ -900,21 +1131,41 @@ impl<B: Backend + 'static> TurboTasks<B> { ) }); - self.priority_runner.schedule(- &self.pin(),- ScheduledTask::LocalTask {- ty,- persistence,- local_task_id,- global_task_state,- span: Span::current(),- },- priority,- );+ let task = ScheduledTask::LocalTask {+ ty,+ persistence,+ execution_id,+ local_task_id,+ global_task_state,+ span: Span::current(),+ };+ self.priority_runner.schedule(&self.pin(), task, priority); RawVc::local_output(execution_id, local_task_id, persistence) } + /// Executes the task inline if possible, returns true if it executed to completion.+ fn try_execute_scheduled_task_inline(&self, key: ScheduleKey) -> bool {+ let this = self.pin();+ self.inline_counters.claim_attempted();+ if let Some(future) = self.priority_runner.claim(&this, &key) {+ let completed = poll_once_or_spawn(future);+ if completed {+ self.inline_counters.claim_completed();+ } else {+ self.inline_counters.claim_yielded();+ }+ return completed;+ }+ self.inline_counters.claim_failed();+ false+ }++ #[cfg(feature = "inline_execution_stats")]+ fn note_waited_for_in_progress_task(&self) {+ self.inline_counters.waited_in_progress();+ }+ fn begin_foreground_job(&self) { if self .currently_scheduled_foreground_jobs@@ -971,6 +1222,22 @@ impl<B: Backend + 'static> TurboTasks<B> { .load(Ordering::Acquire) } + /// Counters describing how reads and inline execution interacted. Diagnostics only; a dump of+ /// these can be requested with `TURBO_ENGINE_INLINE_STATS=1`.+ #[cfg(feature = "inline_execution_stats")]+ #[doc(hidden)]+ pub fn inline_execution_stats(&self) -> InlineExecutionStats {+ let counters = &self.inline_counters;+ InlineExecutionStats {+ queued: self.priority_runner.total_queued(),+ claim_attempted: counters.claim_attempted.load(Ordering::Relaxed),+ claim_completed: counters.claim_completed.load(Ordering::Relaxed),+ claim_yielded: counters.claim_yielded.load(Ordering::Relaxed),+ claim_failed: counters.claim_failed.load(Ordering::Relaxed),+ waited_in_progress: counters.waited_in_progress.load(Ordering::Relaxed),+ }+ }+ /// Waits for the given task to finish executing. This works by performing an untracked read, /// and discarding the value of the task output. ///@@ -1097,6 +1364,15 @@ impl<B: Backend + 'static> TurboTasks<B> { } pub async fn stop_and_wait(&self) {+ #[cfg(feature = "inline_execution_stats")]+ if inline_stats_requested() {+ // Requested with `TURBO_ENGINE_INLINE_STATS=1`; printed rather than traced so it shows+ // up without a tracing subscriber configured.+ eprintln!(+ "turbo-tasks inline execution stats: {:#?}",+ self.inline_execution_stats()+ );+ } turbo_tasks_future_scope(self.pin(), async move { self.backend.stopping(self); self.stopped.store(true, Ordering::Release);@@ -1239,6 +1515,10 @@ impl<B: Backend> Executor<TurboTasks<B>, ScheduledTask, TaskPriority> for TurboT .backend .try_start_task_execution(task_id, priority, &*this)?; + // When a reader claimed this task and is polling it inline, let it+ // record the outcome on this span rather than its own.+ InlineExecutionSpanSlot::set(&span);+ async { let result = CaptureFuture::new(future).await; @@ -1291,6 +1571,7 @@ impl<B: Backend> Executor<TurboTasks<B>, ScheduledTask, TaskPriority> for TurboT ScheduledTask::LocalTask { ty, persistence,+ execution_id: _, local_task_id, global_task_state, span,@@ -1307,6 +1588,9 @@ impl<B: Backend> Executor<TurboTasks<B>, ScheduledTask, TaskPriority> for TurboT trait_method.resolve_span(priority) } };+ // See the cached-task arm: lets a reader that claimed this local task record+ // the outcome of its inline poll on this span.+ InlineExecutionSpanSlot::set(&span); abort_on_panic( async move { let result = match ty.task_type {@@ -1472,7 +1756,7 @@ impl<B: Backend + 'static> TurboTasksApi for TurboTasks<B> { &self, task: TaskId, options: ReadOutputOptions,- ) -> Result<Result<RawVc, EventListener>> {+ ) -> Result<ReadOutcome<RawVc>> { if options.consistency == ReadConsistency::Eventual { debug_assert_not_in_top_level_task("read_task_output"); }@@ -1490,7 +1774,7 @@ impl<B: Backend + 'static> TurboTasksApi for TurboTasks<B> { task: TaskId, index: CellId, options: ReadCellOptions,- ) -> Result<Result<TypedCellContent, EventListener>> {+ ) -> Result<ReadOutcome<TypedCellContent>> { let reader = current_task_if_available("reading Vcs"); self.backend .try_read_task_cell(task, index, reader, options, self)@@ -1539,6 +1823,15 @@ impl<B: Backend + 'static> TurboTasksApi for TurboTasks<B> { ) } + fn try_execute_scheduled_task_inline(&self, key: ScheduleKey) -> bool {+ self.try_execute_scheduled_task_inline(key)+ }++ #[cfg(feature = "inline_execution_stats")]+ fn note_waited_for_in_progress_task(&self) {+ self.note_waited_for_in_progress_task()+ }+ fn emit_collectible(&self, trait_type: TraitTypeId, collectible: RawVc) { self.backend.emit_collectible( trait_type,@@ -1947,8 +2240,20 @@ pub(crate) async fn read_task_output( ) -> Result<RawVc> { loop { match this.try_read_task_output(id, options)? {- Ok(result) => return Ok(result),- Err(listener) => listener.await,+ ReadOutcome::Value(result) => return Ok(result),+ ReadOutcome::Scheduled(listener) => {+ // Nobody has started it yet, so take it over instead of waiting for a worker.+ if execute_read_target_inline(this, ScheduleKey::Task(id)) {+ continue;+ }+ listener.await+ }+ ReadOutcome::InProgress(listener) => {+ // A worker is on it — there is nothing to take over, so don't touch the queue.+ #[cfg(feature = "inline_execution_stats")]+ this.note_waited_for_in_progress_task();+ listener.await+ } } } }@@ -2284,7 +2589,83 @@ pub(crate) async fn read_local_output( loop { match this.try_read_local_output(execution_id, local_task_id)? { Ok(raw_vc) => return Ok(raw_vc),- Err(event_listener) => event_listener.await,+ Err(event_listener) => {+ // The local task is not done yet. If it is only scheduled, execute it right here+ // instead of waiting for a worker to pick it up.+ if execute_read_target_inline(+ this,+ ScheduleKey::LocalTask(execution_id, local_task_id),+ ) {+ continue;+ }+ event_listener.await+ }+ }+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn test_inline_execution_depth_guard_restores_depth() {+ assert_eq!(INLINE_EXECUTION_DEPTH.get(), 0);+ {+ let _outer = InlineExecutionDepthGuard::enter();+ {+ let _inner = InlineExecutionDepthGuard::enter();+ assert_eq!(INLINE_EXECUTION_DEPTH.get(), 2);+ }+ assert_eq!(INLINE_EXECUTION_DEPTH.get(), 1); }+ assert_eq!(INLINE_EXECUTION_DEPTH.get(), 0);+ }++ #[test]+ fn test_inline_depth_cap() {+ assert!(inline_execution_allowed(), "nothing is nested yet");+ let mut guards = (0..MAX_INLINE_EXECUTION_DEPTH)+ .map(|_| InlineExecutionDepthGuard::enter())+ .collect::<Vec<_>>();+ assert_eq!(INLINE_EXECUTION_DEPTH.get(), MAX_INLINE_EXECUTION_DEPTH);+ assert!(+ !inline_execution_allowed(),+ "at the nesting cap reads wait for a worker instead of executing inline"+ );++ // One level below the cap inline execution is allowed again.+ guards.pop();+ assert!(inline_execution_allowed());+ }++ #[tokio::test]+ async fn test_poll_once_or_spawn_completed_execution() {+ assert!(+ poll_once_or_spawn(async {}),+ "a future that completes on the first poll is executed inline"+ );+ assert_eq!(INLINE_EXECUTION_DEPTH.get(), 0);+ }++ #[tokio::test]+ async fn test_poll_once_or_spawn_pending_execution() {+ let (tx, rx) = tokio::sync::oneshot::channel();+ let done = Arc::new(AtomicBool::new(false));+ let done_in_task = done.clone();+ assert!(+ !poll_once_or_spawn(async move {+ // Yields on the first poll, so it cannot be executed inline.+ tokio::task::yield_now().await;+ done_in_task.store(true, Ordering::SeqCst);+ let _ = tx.send(());+ }),+ "a future that yields is not completed inline"+ );+ assert_eq!(INLINE_EXECUTION_DEPTH.get(), 0);++ // ...but it was spawned, so it still runs to completion.+ rx.await.unwrap();+ assert!(done.load(Ordering::SeqCst)); } }turbopack/crates/turbo-tasks/src/native_function.rs11 + / 1 −
@@ -289,6 +289,9 @@ impl NativeFunction { TaskPersistence::Persistent => "", TaskPersistence::Transient => "transient", };+ // `inline_execution` is recorded when a read executed this task on its own thread instead+ // of waiting for a worker: "complete" if it finished there, "partial" if it yielded+ // and was handed to the runtime. It stays unset for a task a worker executed. #[cfg(feature = "task_dirty_cause")] { tracing::trace_span!(@@ -298,6 +301,7 @@ impl NativeFunction { flags = flags, reason = reason.as_str(), cause = cause.map(tracing::field::display),+ inline_execution = tracing::field::Empty, ) } #[cfg(not(feature = "task_dirty_cause"))]@@ -308,11 +312,17 @@ impl NativeFunction { priority = %priority, flags = flags, reason = reason.as_str(),+ inline_execution = tracing::field::Empty, ) } } pub fn resolve_span(&'static self, priority: TaskPriority) -> Span {- tracing::trace_span!("turbo_tasks::resolve_call", name = self.ty.name, priority = %priority)+ tracing::trace_span!(+ "turbo_tasks::resolve_call",+ name = self.ty.name,+ priority = %priority,+ inline_execution = tracing::field::Empty,+ ) } }turbopack/crates/turbo-tasks/src/priority_runner.rs407 + / 46 −
@@ -2,6 +2,7 @@ use std::{ collections::BinaryHeap, fmt::Debug, future::Future,+ hash::Hash, pin::Pin, ptr::drop_in_place, sync::{@@ -14,13 +15,29 @@ use std::{ use parking_lot::Mutex; use pin_project_lite::pin_project;+use rustc_hash::FxHashMap; pub trait Executor<C, T, P>: Send + Sync { type Future: Future<Output = ()> + Send; fn execute(&self, execute_context: &Arc<C>, task: T, priority: P) -> Self::Future; } +/// A queued item that can be claimed by key before a worker starts executing it.+///+/// Claiming is how a reader takes over work it is about to wait for: instead of parking until some+/// worker gets around to the queued item, the reader removes it from the queue (see+/// [`PriorityRunner::claim`]) and drives it itself.+pub trait Claimable {+ type Key: Eq + Hash + Copy + Debug + Send + Sync;++ /// The key this item can be claimed by, or `None` when it must not be claimable.+ ///+ /// When multiple queued items share a key, only the most recently queued one is claimable; the+ /// others stay in the queue and are executed by workers as usual.+ fn claim_key(&self) -> Option<Self::Key>;+}+ impl<C, T, P, F, Fut> Executor<C, T, P> for F where F: Fn(&Arc<C>, T, P) -> Fut + Send + Sync,@@ -33,42 +50,158 @@ where } } -struct HeapItem<P, T> {+struct HeapItem<P> { priority: P,- task: T,+ /// Index into [`Queue::slots`]. The slot holds the queued item, or `None` when it was claimed+ /// (see [`Queue::claim`]).+ slot: usize, } -impl<P: Eq, T> PartialEq for HeapItem<P, T> {+impl<P: Eq> PartialEq for HeapItem<P> { fn eq(&self, other: &Self) -> bool { self.priority == other.priority } } -impl<P: Eq, T> Eq for HeapItem<P, T> {}+impl<P: Eq> Eq for HeapItem<P> {} -impl<P: Ord, T> Ord for HeapItem<P, T> {+impl<P: Ord> Ord for HeapItem<P> { fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.priority.cmp(&other.priority) } } -impl<P: Ord, T> PartialOrd for HeapItem<P, T> {+impl<P: Ord> PartialOrd for HeapItem<P> { fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { Some(self.cmp(other)) } } +/// The queue of items that are not scheduled yet.+///+/// Items are ordered by priority in a [`BinaryHeap`], but they are stored out-of-line in `slots` so+/// that a single item can be removed by key without disturbing the heap (a binary heap has no+/// keyed removal). Claiming an item takes the value out of its slot and leaves the heap entry+/// behind as a tombstone, which is skipped (and its slot recycled) when a worker pops it.+struct Queue<P, T: Claimable> {+ heap: BinaryHeap<HeapItem<P>>,+ /// The queued items with their priority. A slot is `Some` while the item is queued, `None` if+ /// it was claimed. The slot itself is only recycled once its (tombstone) heap entry has+ /// been popped, so a slot index is never reused while it is still referenced by the heap.+ ///+ /// The priority is stored here as well as in the heap entry because [`Queue::claim`] finds an+ /// item by key and never touches the heap, so unlike [`Queue::pop`] it has no heap entry to+ /// read it from — and [`Executor::execute`] needs the priority.+ slots: Vec<Option<(P, T)>>,+ /// Recycled indices into `slots`.+ free_slots: Vec<usize>,+ /// Slot index of the claimable item for each key.+ claimable: FxHashMap<T::Key, usize>,+ /// How many items were ever pushed. Diagnostics only, see [`PriorityRunner::total_queued`].+ #[cfg(feature = "inline_execution_stats")]+ pushes: u64,+}++impl<P: Clone + Ord, T: Claimable> Queue<P, T> {+ fn new() -> Self {+ Self {+ heap: BinaryHeap::new(),+ slots: Vec::new(),+ free_slots: Vec::new(),+ claimable: FxHashMap::default(),+ #[cfg(feature = "inline_execution_stats")]+ pushes: 0,+ }+ }++ /// Whether there is any heap entry left. This can be `true` while all remaining entries are+ /// tombstones of claimed items; popping is what cleans those up.+ fn is_empty(&self) -> bool {+ self.heap.is_empty()+ }++ fn push(&mut self, priority: P, task: T) {+ #[cfg(feature = "inline_execution_stats")]+ {+ self.pushes += 1;+ }+ let key = task.claim_key();+ let heap_priority = priority.clone();+ let slot = if let Some(slot) = self.free_slots.pop() {+ self.slots[slot] = Some((priority, task));+ slot+ } else {+ self.slots.push(Some((priority, task)));+ self.slots.len() - 1+ };+ if let Some(key) = key {+ // If this key is already queued, the older item stops being claimable. It stays in the+ // queue and is executed by a worker as usual.+ self.claimable.insert(key, slot);+ }+ self.heap.push(HeapItem {+ priority: heap_priority,+ slot,+ });+ }++ /// Pops the highest priority item, skipping tombstones of claimed items.+ fn pop(&mut self) -> Option<(P, T)> {+ while let Some(HeapItem { slot, .. }) = self.heap.pop() {+ let entry = self.slots[slot].take();+ self.free_slots.push(slot);+ if let Some((priority, task)) = entry {+ if let Some(key) = task.claim_key() {+ // Only remove the mapping when it still points at this item. A newer item with+ // the same key must stay claimable.+ if self.claimable.get(&key) == Some(&slot) {+ self.claimable.remove(&key);+ }+ }+ self.shrink_amortized();+ return Some((priority, task));+ }+ }+ self.shrink_amortized();+ None+ }++ /// Removes the queued item with the given key, if it is still queued and claimable.+ fn claim(&mut self, key: &T::Key) -> Option<(P, T)> {+ let slot = self.claimable.remove(key)?;+ // The slot is intentionally not recycled here: its heap entry is still around as a+ // tombstone and must not start pointing at a different item.+ self.slots.get_mut(slot).and_then(|slot| slot.take())+ }++ /// Amortized shrinking of the queue, but with a lower threshold to avoid+ /// frequent reallocations when the queue is small.+ fn shrink_amortized(&mut self) {+ if self.heap.capacity() > self.heap.len() * 3 && self.heap.capacity() > 128 {+ let new_capacity = self.heap.len().next_power_of_two().max(128);+ self.heap.shrink_to(new_capacity);+ }+ if self.heap.is_empty() && self.claimable.is_empty() && self.slots.capacity() > 128 {+ // Nothing references any slot anymore.+ self.slots.clear();+ self.slots.shrink_to(128);+ self.free_slots.clear();+ self.free_slots.shrink_to(128);+ }+ }+}+ pub struct PriorityRunner< C: Send + Sync + 'static,- T: Send + 'static,- P: Ord + Send + 'static,+ T: Claimable + Send + 'static,+ P: Clone + Ord + Send + 'static, E: Executor<C, T, P> + 'static, > { executor: E, /// The target number of workers to spawn. target_workers: usize, /// The queue of tasks to execute. These tasks are not scheduled yet.- queue: Mutex<BinaryHeap<HeapItem<P, T>>>,+ queue: Mutex<Queue<P, T>>, /// The number of active workers currently polling tasks. /// Workers that responded with Poll::Pending are not counted until they are polled again. active_workers: AtomicUsize,@@ -77,28 +210,47 @@ pub struct PriorityRunner< impl< C: Send + Sync + 'static,- T: Send + 'static,- P: Debug + Ord + Send + 'static,+ T: Claimable + Send + 'static,+ P: Clone + Debug + Ord + Send + 'static, E: Executor<C, T, P> + 'static, > PriorityRunner<C, T, P, E> { pub fn new(executor: E) -> Self {+ Self::with_target_workers(+ executor,+ tokio::runtime::Handle::current().metrics().num_workers(),+ )+ }++ fn with_target_workers(executor: E, target_workers: usize) -> Self { Self { executor,- target_workers: tokio::runtime::Handle::current().metrics().num_workers(),- queue: Mutex::new(BinaryHeap::new()),+ target_workers,+ queue: Mutex::new(Queue::new()), active_workers: AtomicUsize::new(0), phantom: std::marker::PhantomData, } } + /// How many tasks were ever put into the queue, as opposed to being executed without ever being+ /// queued. Diagnostics only — it lets a test assert that a task never took the detour through+ /// the queue.+ #[cfg(feature = "inline_execution_stats")]+ pub fn total_queued(&self) -> u64 {+ self.queue.lock().pushes+ }+ pub fn schedule(self: &Arc<Self>, execute_context: &Arc<C>, task: T, priority: P) { let mut queue = self.queue.lock(); if !queue.is_empty() { // If there is already work in the queue, we don't have any // free capacity so we can just push the task to the queue. // It will be picked up by existing workers.- queue.push(HeapItem { priority, task });+ //+ // A worker only stops when it finds the queue empty, so a non-empty queue always has a+ // worker that will drain it. [`claim`](Self::claim) does take work out of the queue+ // without being a worker, but that only ever makes the queue shorter.+ queue.push(priority, task); return; } // The queue is empty, so we might have free capacity to spawn a new worker.@@ -111,14 +263,25 @@ impl< WorkerFuture::spawn(future, execute_context.clone(), self.clone()); } else { // No free capacity, push the task to the queue.- queue.push(HeapItem { priority, task });+ queue.push(priority, task); drop(queue); // Undo the added active worker since we didn't spawn a new worker. self.decrease_active_workers(execute_context); } } + /// Takes the queued task with the given key out of the queue and returns its execution future,+ /// or `None` when there is no such task in the queue (it was never scheduled, a worker already+ /// picked it up, or it was claimed before).+ ///+ /// The caller takes over the responsibility to drive the returned future to completion; the+ /// task left the queue, so no worker will do it.+ pub fn claim(&self, execute_context: &Arc<C>, key: &T::Key) -> Option<E::Future> {+ let (priority, task) = self.queue.lock().claim(key)?;+ Some(self.executor.execute(execute_context, task, priority))+ }+ /// Tries to decrease the active worker count by 1. /// If there is work available in the queue, a new worker is spawned instead. fn reuse_or_decrease_active_workers(self: &Arc<Self>, execute_context: &Arc<C>) {@@ -148,31 +311,18 @@ impl< } fn pop_future_from_worker(&self, execute_context: &Arc<C>) -> Option<E::Future> {- let mut queue = self.queue.lock();- if let Some(heap_item) = queue.pop() {- shrink_amortized(&mut queue);- drop(queue);- Some(- self.executor- .execute(execute_context, heap_item.task, heap_item.priority),- )- } else {- None- }+ let popped = self.queue.lock().pop();+ popped.map(|(priority, task)| self.executor.execute(execute_context, task, priority)) } fn spawn_worker_if_work_available( self: &Arc<Self>, execute_context: &Arc<C>, unused_active_count: bool, ) -> bool {- let mut queue = self.queue.lock();- if let Some(heap_item) = queue.pop() {- shrink_amortized(&mut queue);- drop(queue);- let new_future =- self.executor- .execute(execute_context, heap_item.task, heap_item.priority);+ let popped = self.queue.lock().pop();+ if let Some((priority, task)) = popped {+ let new_future = self.executor.execute(execute_context, task, priority); if !unused_active_count { self.active_workers.fetch_add(1, Ordering::Relaxed);@@ -185,15 +335,6 @@ impl< } } -fn shrink_amortized<P, T>(queue: &mut BinaryHeap<HeapItem<P, T>>) {- // Amortized shrinking of the queue, but with a lower threshold to avoid- // frequent reallocations when the queue is small.- if queue.capacity() > queue.len() * 3 && queue.capacity() > 128 {- let new_capacity = queue.len().next_power_of_two().max(128);- queue.shrink_to(new_capacity);- }-}- #[derive(Debug)] enum WorkerState { UnfinishedFuture,@@ -209,8 +350,10 @@ pin_project! { C: Send, C: Sync, C: 'static,+ T: Claimable, T: Send, T: 'static,+ P: Clone, P: Ord, P: Send, P: 'static,@@ -228,8 +371,8 @@ pin_project! { impl< C: Send + Sync + 'static,- T: Send + 'static,- P: Debug + Ord + Send + 'static,+ T: Claimable + Send + 'static,+ P: Clone + Debug + Ord + Send + 'static, E: Executor<C, T, P> + 'static, > WorkerFuture<C, T, P, E> {@@ -245,8 +388,8 @@ impl< impl< C: Send + Sync + 'static,- T: Send + 'static,- P: Debug + Ord + Send + 'static,+ T: Claimable + Send + 'static,+ P: Clone + Debug + Ord + Send + 'static, E: Executor<C, T, P> + 'static, > Future for WorkerFuture<C, T, P, E> {@@ -336,6 +479,224 @@ mod tests { use super::*; + impl Claimable for u32 {+ type Key = u32;++ fn claim_key(&self) -> Option<u32> {+ Some(*self)+ }+ }++ impl Claimable for (u32, bool) {+ type Key = u32;++ fn claim_key(&self) -> Option<u32> {+ Some(self.0)+ }+ }++ /// An item that is never claimable, to check that `None` keys are queued and executed as usual.+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]+ struct Unkeyed(u32);++ impl Claimable for Unkeyed {+ type Key = u32;++ fn claim_key(&self) -> Option<u32> {+ None+ }+ }++ /// An executor that records which items it was asked to execute, in order, and whose futures+ /// complete immediately. Lets the queue be driven without a tokio runtime.+ struct RecordingExecutor;++ impl<T: Claimable + Copy + Send + Sync + Debug + 'static> Executor<Mutex<Vec<T>>, T, u32>+ for RecordingExecutor+ {+ type Future = std::future::Ready<()>;++ fn execute(+ &self,+ execute_context: &Arc<Mutex<Vec<T>>>,+ task: T,+ _priority: u32,+ ) -> Self::Future {+ execute_context.lock().push(task);+ std::future::ready(())+ }+ }++ /// The recorded executions of a test runner, in execution order.+ type Executions<T> = Arc<Mutex<Vec<T>>>;+ /// A test runner over items of type `T`.+ type TestRunner<T> = Arc<PriorityRunner<Mutex<Vec<T>>, T, u32, RecordingExecutor>>;++ /// A runner that queues every scheduled item (`target_workers == 0`, so no worker is ever+ /// spawned) and therefore needs no tokio runtime. `pop_future_from_worker` stands in for what a+ /// worker would do.+ fn queueing_runner<T: Claimable + Copy + Send + Sync + Debug + 'static>()+ -> (TestRunner<T>, Executions<T>) {+ (+ Arc::new(PriorityRunner::with_target_workers(RecordingExecutor, 0)),+ Arc::new(Mutex::new(Vec::new())),+ )+ }++ /// Drains the queue the way workers would and returns the items in execution order.+ fn drain<T: Claimable + Copy + Send + Sync + Debug + 'static>(+ runner: &TestRunner<T>,+ executed: &Executions<T>,+ ) -> Vec<T> {+ while runner.pop_future_from_worker(executed).is_some() {}+ let items = executed.lock().clone();+ executed.lock().clear();+ items+ }++ #[test]+ fn test_claim_queued_entry_by_key() {+ let (runner, executed) = queueing_runner::<u32>();+ for task in 0..4 {+ runner.schedule(&executed, task, task);+ }++ // Claiming builds the execution future, which the recording executor counts as executed.+ assert!(runner.claim(&executed, &2).is_some());+ assert_eq!(*executed.lock(), vec![2]);+ executed.lock().clear();++ // The claimed entry is gone from the queue; everything else still runs, highest priority+ // first.+ assert_eq!(drain(&runner, &executed), vec![3, 1, 0]);+ }++ #[test]+ fn test_claim_unknown_key_returns_none() {+ let (runner, executed) = queueing_runner::<u32>();+ runner.schedule(&executed, 1, 1);++ // Never scheduled.+ assert!(runner.claim(&executed, &42).is_none());+ // Already executed by a "worker".+ assert_eq!(drain(&runner, &executed), vec![1]);+ assert!(runner.claim(&executed, &1).is_none());+ assert!(executed.lock().is_empty());+ }++ #[test]+ fn test_claim_twice_returns_none() {+ let (runner, executed) = queueing_runner::<u32>();+ runner.schedule(&executed, 7, 7);++ assert!(runner.claim(&executed, &7).is_some());+ assert!(runner.claim(&executed, &7).is_none());+ assert_eq!(*executed.lock(), vec![7]);+ executed.lock().clear();++ // Only a tombstone is left.+ assert!(drain(&runner, &executed).is_empty());+ }++ #[test]+ fn test_claimed_entry_is_executed_exactly_once() {+ let (runner, executed) = queueing_runner::<u32>();+ for task in 0..10 {+ runner.schedule(&executed, task, task);+ }+ for task in [0, 5, 9] {+ assert!(runner.claim(&executed, &task).is_some());+ }+ let mut all = drain(&runner, &executed);+ all.sort_unstable();+ // Every scheduled item was executed exactly once: three by the claimer, the rest by+ // "workers".+ assert_eq!(all, (0..10).collect::<Vec<_>>());+ }++ #[test]+ fn test_claim_preserves_priority_order() {+ let (runner, executed) = queueing_runner::<u32>();+ for task in 0..6 {+ runner.schedule(&executed, task, task);+ }+ assert!(runner.claim(&executed, &4).is_some());+ executed.lock().clear();++ assert_eq!(drain(&runner, &executed), vec![5, 3, 2, 1, 0]);+ }++ #[test]+ fn test_duplicate_keys() {+ let (runner, executed) = queueing_runner::<(u32, bool)>();+ // Both items share the claim key `1`.+ runner.schedule(&executed, (1, false), 1);+ runner.schedule(&executed, (1, true), 2);++ // The most recently queued item is the claimable one.+ assert!(runner.claim(&executed, &1).is_some());+ assert_eq!(*executed.lock(), vec![(1, true)]);+ executed.lock().clear();+ // The other one is not claimable anymore, but it is not lost either.+ assert!(runner.claim(&executed, &1).is_none());+ assert_eq!(drain(&runner, &executed), vec![(1, false)]);+ }++ #[test]+ fn test_unkeyed_entries_are_not_claimable() {+ let (runner, executed) = queueing_runner::<Unkeyed>();+ runner.schedule(&executed, Unkeyed(1), 1);+ runner.schedule(&executed, Unkeyed(2), 2);++ assert!(runner.claim(&executed, &1).is_none());+ assert_eq!(+ drain(&runner, &executed),+ vec![Unkeyed(2), Unkeyed(1)],+ "unkeyed items are queued and executed as usual"+ );+ }++ #[test]+ fn test_slots_are_recycled() {+ let (runner, executed) = queueing_runner::<u32>();+ for _ in 0..100 {+ for task in 0..8 {+ runner.schedule(&executed, task, task);+ }+ // Claim one of them each round, so tombstones are part of the cycle.+ assert!(runner.claim(&executed, &3).is_some());+ drain(&runner, &executed);+ let queue = runner.queue.lock();+ assert!(queue.is_empty());+ assert!(+ queue.slots.len() <= 8,+ "slots should be recycled, got {}",+ queue.slots.len()+ );+ assert!(+ queue.claimable.is_empty(),+ "claimable index should be empty when the queue is empty"+ );+ }+ }++ /// Every push into the queue is counted, so a test can assert that a task was executed without+ /// ever being queued.+ #[cfg(feature = "inline_execution_stats")]+ #[test]+ fn test_total_queued_counts_pushes() {+ let (runner, executed) = queueing_runner::<u32>();+ assert_eq!(runner.total_queued(), 0);+ for task in 0..3 {+ runner.schedule(&executed, task, task);+ }+ assert_eq!(runner.total_queued(), 3);+ // Claiming and draining do not change how many pushes happened.+ assert!(runner.claim(&executed, &1).is_some());+ drain(&runner, &executed);+ assert_eq!(runner.total_queued(), 3);+ }+ #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_cpu_bound_tasks() { struct ExecutorImpl;turbopack/crates/turbo-tasks/src/read_options.rs22 + / 1 −
@@ -1,4 +1,25 @@-use crate::{ReadConsistency, ReadTracking, manager::ReadCellTracking};+use crate::{ReadConsistency, ReadTracking, event::EventListener, manager::ReadCellTracking};++/// What a read of a task's output or cell found.+///+/// The two "not available yet" cases are distinguished because the reader can act on them+/// differently: a task that is only *scheduled* can be taken over and executed by the reader, while+/// one that some worker is already executing can only be waited for — and finding that out without+/// taking the scheduler's queue lock is the point of telling them apart.+///+/// The state is a **hint**: it is a snapshot, and a task can be picked up by a worker right after+/// the read looked (a worker even pops a task off the queue before it marks it as started). Acting+/// on a stale hint costs at most a failed claim, never correctness.+pub enum ReadOutcome<T> {+ /// The value is available.+ Value(T),+ /// The task is queued but no worker has started it, so the reader may take it out of the queue+ /// and execute it itself. The listener fires when the task is done.+ Scheduled(EventListener),+ /// A worker is already executing the task; there is nothing to take over. The listener fires+ /// when the task is done.+ InProgress(EventListener),+} #[derive(Clone, Copy, Debug, Default)] pub struct ReadCellOptions {turbopack/crates/turbo-tasks/src/vc/raw.rs101 + / 24 −
@@ -19,8 +19,10 @@ use crate::{ id::{ExecutionId, LocalTaskId, TASK_ID_MAX}, manager::{ ReadCellTracking, ReadTracking, SUPPRESS_EVENTUAL_CONSISTENCY_TOP_LEVEL_TASK_CHECK,- TurboTasksApi, read_local_output, with_turbo_tasks,+ ScheduleKey, TurboTasksApi, execute_read_target_inline, read_local_output,+ with_turbo_tasks, },+ read_options::ReadOutcome, registry::get_value_type, turbo_tasks, };@@ -502,6 +504,15 @@ fn suppress_top_level_task_check<R>(strongly_consistent: bool, f: impl FnOnce() } } +/// Executes the task a read is waiting for when it is only scheduled, so the read can continue+/// without waiting for a worker.+///+/// Must be called *outside* [`with_turbo_tasks`]: executing a task enters a task-local scope of its+/// own, which panics while the read borrows `TURBO_TASKS`.+fn execute_inline(key: ScheduleKey) {+ execute_read_target_inline(&*turbo_tasks(), key);+}+ #[must_use] pub struct ResolveRawVcFuture { current: RawVc,@@ -551,14 +562,20 @@ impl Future for ResolveRawVcFuture { // SAFETY: we are not moving self let this = unsafe { self.get_unchecked_mut() }; - let poll_fn = |tt: &Arc<dyn TurboTasksApi>| -> Poll<Self::Output> {+ let strongly_consistent = this.strongly_consistent;+ // `execute_inline` is the task to execute inline, reported out-of-band so that the value+ // path stays exactly as cheap as it was. It is only set for tasks that are merely+ // scheduled; one that a worker is already executing cannot be taken over.+ let mut poll_fn = |tt: &Arc<dyn TurboTasksApi>,+ execute_inline: &mut Option<ScheduleKey>|+ -> Poll<Self::Output> { 'outer: loop { ready!(poll_listener(&mut this.listener, cx));- let listener = match this.current.unpack() {+ let (listener, key) = match this.current.unpack() { RawVcUnpacked::TaskOutput(task) => { let read_result = tt.try_read_task_output(task, this.read_output_options); match read_result {- Ok(Ok(vc)) => {+ Ok(ReadOutcome::Value(vc)) => { // turbo-tasks-backend doesn't currently have any sort of // "transaction" or global lock mechanism to group together chains // of `TaskOutput`/`TaskCell` reads.@@ -571,7 +588,19 @@ impl Future for ResolveRawVcFuture { this.current = vc; continue 'outer; }- Ok(Err(listener)) => listener,+ // Nobody has started the task yet, so the caller may take it over.+ Ok(ReadOutcome::Scheduled(listener)) => {+ (listener, Some(ScheduleKey::Task(task)))+ }+ Ok(ReadOutcome::InProgress(listener)) => {+ // A worker is on it; nothing to take over. Loop back so+ // `poll_listener` registers the waker on this listener — returning+ // `Pending` here would sleep through the event.+ #[cfg(feature = "inline_execution_stats")]+ tt.note_waited_for_in_progress_task();+ this.listener = Some(listener);+ continue 'outer;+ } Err(err) => return Poll::Ready(Err(err)), } }@@ -587,22 +616,40 @@ impl Future for ResolveRawVcFuture { this.current = vc; continue 'outer; }- Ok(Err(listener)) => listener,+ Ok(Err(listener)) => (+ listener,+ Some(ScheduleKey::LocalTask(execution_id, local_task_id)),+ ), Err(err) => return Poll::Ready(Err(err)), } } };+ // The task is not done yet, so we have to wait for it — unless it is merely+ // scheduled, in which case our caller executes it and we read again. this.listener = Some(listener);+ *execute_inline = key;+ return Poll::Pending; } }; - // HACK: Temporarily suppress top-level task check if doing strongly consistent read.- //- // This masks a bug: There's an unlikely TOCTOU race condition in `poll_fn`. Because the- // strongly consistent read isn't a single atomic operation, any inner `TaskOutput` or- // `TaskCell` could get mutated after the strongly consistent read of the outer- // `TaskOutput`.- suppress_top_level_task_check(this.strongly_consistent, || with_turbo_tasks(poll_fn))+ loop {+ let mut execute_inline_key = None;+ // HACK: Temporarily suppress top-level task check if doing strongly consistent read.+ //+ // This masks a bug: There's an unlikely TOCTOU race condition in `poll_fn`. Because the+ // strongly consistent read isn't a single atomic operation, any inner `TaskOutput` or+ // `TaskCell` could get mutated after the strongly consistent read of the outer+ // `TaskOutput`.+ let result = suppress_top_level_task_check(strongly_consistent, || {+ with_turbo_tasks(|tt| poll_fn(tt, &mut execute_inline_key))+ });+ if let Some(key) = execute_inline_key {+ // Not inside `with_turbo_tasks`, see `execute_inline`.+ execute_inline(key);+ continue;+ }+ return result;+ } } } @@ -724,23 +771,53 @@ impl Future for ReadRawVcFuture { let index = *index; let read_cell_options = this.read_cell_options; - let poll_fn = |tt: &Arc<dyn TurboTasksApi>| -> Poll<Self::Output> {+ let strongly_consistent = *strongly_consistent;++ let mut poll_fn = |tt: &Arc<dyn TurboTasksApi>,+ execute_inline: &mut Option<ScheduleKey>|+ -> Poll<Self::Output> { loop { ready!(poll_listener(listener, cx));- let new_listener = match tt.try_read_task_cell(task, index, read_cell_options) {- Ok(Ok(content)) => return Poll::Ready(Ok(content)),- Ok(Err(l)) => l,- Err(err) => return Poll::Ready(Err(err)),- };+ let (new_listener, key) =+ match tt.try_read_task_cell(task, index, read_cell_options) {+ Ok(ReadOutcome::Value(content)) => return Poll::Ready(Ok(content)),+ Ok(ReadOutcome::Scheduled(l)) => (l, Some(ScheduleKey::Task(task))),+ Ok(ReadOutcome::InProgress(l)) => {+ // A worker is already filling the cell; nothing to take over. Loop back+ // so `poll_listener` registers the waker on this listener — returning+ // `Pending` here would sleep through the event.+ #[cfg(feature = "inline_execution_stats")]+ tt.note_waited_for_in_progress_task();+ *listener = Some(l);+ continue;+ }+ Err(err) => return Poll::Ready(Err(err)),+ };+ // The cell isn't available yet, so we have to wait for the task that fills it —+ // unless that task is merely scheduled, in which case our caller executes it and we+ // read again. *listener = Some(new_listener);+ *execute_inline = key;+ return Poll::Pending; } }; - // Phase 2 must also suppress the top-level task check when phase 1 was- // strongly-consistent. The suppression from `ResolveRawVcFuture::poll` only lasts for- // the duration of that individual `poll` call and does not carry over to subsequent calls- // or to this phase.- suppress_top_level_task_check(*strongly_consistent, || with_turbo_tasks(poll_fn))+ loop {+ let mut execute_inline_key = None;+ // Phase 2 must also suppress the top-level task check when phase 1 was+ // strongly-consistent. The suppression from `ResolveRawVcFuture::poll` only lasts for+ // the duration of that individual `poll` call and does not carry over to subsequent+ // calls or to this phase.+ let result = suppress_top_level_task_check(strongly_consistent, || {+ with_turbo_tasks(|tt| poll_fn(tt, &mut execute_inline_key))+ });+ if let Some(key) = execute_inline_key {+ // Not inside `with_turbo_tasks`, see `execute_inline`.+ execute_inline(key);+ continue;+ }+ return result;+ } } }