Skip to content

Commit 4952f3d

Browse files
committed
perf(turbo-tasks): optimize empty iterator/collection handling in async operations
- Add early return optimization for empty iterators in join_iter_ext.rs (Join, TryJoin, TryFlatJoin) to avoid JoinAll heap allocation overhead - Add early return for empty collections in task_input.rs resolve_input implementations (Vec, BTreeMap, BTreeSet, FrozenMap, FrozenSet) - These optimizations avoid async state machine overhead when processing empty collections
1 parent b46d599 commit 4952f3d

File tree

2 files changed

+152
-28
lines changed

2 files changed

+152
-28
lines changed

turbopack/crates/turbo-tasks/src/join_iter_ext.rs

Lines changed: 128 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,29 @@ use pin_project_lite::pin_project;
1313

1414
pin_project! {
1515
/// Future for the [JoinIterExt::join] method.
16+
///
17+
/// Uses an enum to optimize the empty iterator case by avoiding
18+
/// heap allocation and executor overhead when there are no futures to join.
1619
pub struct Join<F>
1720
where
1821
F: Future,
1922
{
2023
#[pin]
21-
inner: JoinAll<F>,
24+
inner: JoinInner<F>,
25+
}
26+
}
27+
28+
pin_project! {
29+
#[project = JoinInnerProj]
30+
enum JoinInner<F>
31+
where
32+
F: Future,
33+
{
34+
Empty,
35+
NonEmpty {
36+
#[pin]
37+
inner: JoinAll<F>,
38+
},
2239
}
2340
}
2441

@@ -32,7 +49,10 @@ where
3249
self: std::pin::Pin<&mut Self>,
3350
cx: &mut std::task::Context<'_>,
3451
) -> std::task::Poll<Self::Output> {
35-
self.project().inner.poll(cx)
52+
match self.project().inner.project() {
53+
JoinInnerProj::Empty => std::task::Poll::Ready(Vec::new()),
54+
JoinInnerProj::NonEmpty { inner } => inner.poll(cx),
55+
}
3656
}
3757
}
3858

@@ -42,18 +62,39 @@ where
4262
{
4363
/// Returns a future that resolves to a vector of the outputs of the futures
4464
/// in the iterator.
65+
///
66+
/// This method is optimized for empty iterators - when the iterator is empty,
67+
/// it avoids creating a `JoinAll` future and its associated heap allocation,
68+
/// returning a ready future directly instead.
4569
fn join(self) -> Join<F>;
4670
}
4771

4872
pin_project! {
4973
/// Future for the [TryJoinIterExt::try_join] method.
74+
///
75+
/// Uses `Either` to optimize the empty iterator case by avoiding
76+
/// heap allocation and executor overhead when there are no futures to join.
5077
#[must_use]
5178
pub struct TryJoin<F>
5279
where
5380
F: Future,
5481
{
5582
#[pin]
56-
inner: JoinAll<F>,
83+
inner: TryJoinInner<F>,
84+
}
85+
}
86+
87+
pin_project! {
88+
#[project = TryJoinInnerProj]
89+
enum TryJoinInner<F>
90+
where
91+
F: Future,
92+
{
93+
Empty,
94+
NonEmpty {
95+
#[pin]
96+
inner: JoinAll<F>,
97+
},
5798
}
5899
}
59100

@@ -67,11 +108,14 @@ where
67108
self: std::pin::Pin<&mut Self>,
68109
cx: &mut std::task::Context<'_>,
69110
) -> std::task::Poll<Self::Output> {
70-
match self.project().inner.poll_unpin(cx) {
71-
std::task::Poll::Ready(res) => {
72-
std::task::Poll::Ready(res.into_iter().collect::<Result<Vec<_>>>())
73-
}
74-
std::task::Poll::Pending => std::task::Poll::Pending,
111+
match self.project().inner.project() {
112+
TryJoinInnerProj::Empty => std::task::Poll::Ready(Ok(Vec::new())),
113+
TryJoinInnerProj::NonEmpty { mut inner } => match inner.poll_unpin(cx) {
114+
std::task::Poll::Ready(res) => {
115+
std::task::Poll::Ready(res.into_iter().collect::<Result<Vec<_>>>())
116+
}
117+
std::task::Poll::Pending => std::task::Poll::Pending,
118+
},
75119
}
76120
}
77121
}
@@ -85,6 +129,10 @@ where
85129
///
86130
/// Unlike `Futures::future::try_join_all`, this returns the Error that
87131
/// occurs first in the list of futures, not the first to fail in time.
132+
///
133+
/// This method is optimized for empty iterators - when the iterator is empty,
134+
/// it avoids creating a `JoinAll` future and its associated heap allocation,
135+
/// returning a ready future directly instead.
88136
fn try_join(self) -> TryJoin<F>;
89137
}
90138

@@ -95,8 +143,19 @@ where
95143
It: Iterator<Item = IF>,
96144
{
97145
fn join(self) -> Join<F> {
98-
Join {
99-
inner: join_all(self.map(|f| f.into_future())),
146+
// Collect futures into a Vec first to enable empty check optimization.
147+
// This avoids heap allocation from JoinAll when the iterator is empty.
148+
let futures: Vec<F> = self.map(|f| f.into_future()).collect();
149+
if futures.is_empty() {
150+
Join {
151+
inner: JoinInner::Empty,
152+
}
153+
} else {
154+
Join {
155+
inner: JoinInner::NonEmpty {
156+
inner: join_all(futures),
157+
},
158+
}
100159
}
101160
}
102161
}
@@ -108,20 +167,48 @@ where
108167
It: Iterator<Item = IF>,
109168
{
110169
fn try_join(self) -> TryJoin<F> {
111-
TryJoin {
112-
inner: join_all(self.map(|f| f.into_future())),
170+
// Collect futures into a Vec first to enable empty check optimization.
171+
// This avoids heap allocation from JoinAll when the iterator is empty.
172+
let futures: Vec<F> = self.map(|f| f.into_future()).collect();
173+
if futures.is_empty() {
174+
TryJoin {
175+
inner: TryJoinInner::Empty,
176+
}
177+
} else {
178+
TryJoin {
179+
inner: TryJoinInner::NonEmpty {
180+
inner: join_all(futures),
181+
},
182+
}
113183
}
114184
}
115185
}
116186

117187
pin_project! {
118188
/// Future for the [TryFlatJoinIterExt::try_flat_join] method.
189+
///
190+
/// Uses an enum to optimize the empty iterator case by avoiding
191+
/// heap allocation and executor overhead when there are no futures to join.
119192
pub struct TryFlatJoin<F>
120193
where
121194
F: Future,
122195
{
123196
#[pin]
124-
inner: JoinAll<F>,
197+
inner: TryFlatJoinInner<F>,
198+
}
199+
}
200+
201+
pin_project! {
202+
#[project = TryFlatJoinInnerProj]
203+
enum TryFlatJoinInner<F>
204+
where
205+
F: Future,
206+
{
207+
Empty,
208+
NonEmpty {
209+
#[pin]
210+
inner: JoinAll<F>,
211+
},
125212
}
126213
}
127214

@@ -134,16 +221,18 @@ where
134221
type Output = Result<Vec<U::Item>>;
135222

136223
fn poll(self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll<Self::Output> {
137-
match self.project().inner.poll_unpin(cx) {
138-
Poll::Ready(res) => {
139-
let mut v = Vec::new();
140-
for r in res {
141-
v.extend(r?);
224+
match self.project().inner.project() {
225+
TryFlatJoinInnerProj::Empty => Poll::Ready(Ok(Vec::new())),
226+
TryFlatJoinInnerProj::NonEmpty { mut inner } => match inner.poll_unpin(cx) {
227+
Poll::Ready(res) => {
228+
let mut v = Vec::new();
229+
for r in res {
230+
v.extend(r?);
231+
}
232+
Poll::Ready(Ok(v))
142233
}
143-
144-
Poll::Ready(Ok(v))
145-
}
146-
Poll::Pending => Poll::Pending,
234+
Poll::Pending => Poll::Pending,
235+
},
147236
}
148237
}
149238
}
@@ -161,6 +250,10 @@ where
161250
///
162251
/// Unlike `Futures::future::try_join_all`, this returns the Error that
163252
/// occurs first in the list of futures, not the first to fail in time.
253+
///
254+
/// This method is optimized for empty iterators - when the iterator is empty,
255+
/// it avoids creating a `JoinAll` future and its associated heap allocation,
256+
/// returning a ready future directly instead.
164257
fn try_flat_join(self) -> TryFlatJoin<F>;
165258
}
166259

@@ -173,8 +266,19 @@ where
173266
U: Iterator,
174267
{
175268
fn try_flat_join(self) -> TryFlatJoin<F> {
176-
TryFlatJoin {
177-
inner: join_all(self.map(|f| f.into_future())),
269+
// Collect futures into a Vec first to enable empty check optimization.
270+
// This avoids heap allocation from JoinAll when the iterator is empty.
271+
let futures: Vec<F> = self.map(|f| f.into_future()).collect();
272+
if futures.is_empty() {
273+
TryFlatJoin {
274+
inner: TryFlatJoinInner::Empty,
275+
}
276+
} else {
277+
TryFlatJoin {
278+
inner: TryFlatJoinInner::NonEmpty {
279+
inner: join_all(futures),
280+
},
281+
}
178282
}
179283
}
180284
}

turbopack/crates/turbo-tasks/src/task/task_input.rs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,15 @@ where
8888
}
8989

9090
async fn resolve_input(&self) -> Result<Self> {
91-
let mut resolved = Vec::with_capacity(self.len());
92-
for value in self {
93-
resolved.push(value.resolve_input().await?);
91+
// Early return for empty vec avoids async state machine overhead
92+
if self.is_empty() {
93+
return Ok(Vec::new());
94+
}
95+
let mut result = Vec::with_capacity(self.len());
96+
for v in self {
97+
result.push(v.resolve_input().await?);
9498
}
95-
Ok(resolved)
99+
Ok(result)
96100
}
97101
}
98102

@@ -259,6 +263,10 @@ where
259263
V: TaskInput,
260264
{
261265
async fn resolve_input(&self) -> Result<Self> {
266+
// Early return for empty map avoids async state machine overhead
267+
if self.is_empty() {
268+
return Ok(BTreeMap::new());
269+
}
262270
let mut new_map = BTreeMap::new();
263271
for (k, v) in self {
264272
new_map.insert(
@@ -285,6 +293,10 @@ where
285293
T: TaskInput + Ord,
286294
{
287295
async fn resolve_input(&self) -> Result<Self> {
296+
// Early return for empty set avoids async state machine overhead
297+
if self.is_empty() {
298+
return Ok(BTreeSet::new());
299+
}
288300
let mut new_set = BTreeSet::new();
289301
for value in self {
290302
new_set.insert(TaskInput::resolve_input(value).await?);
@@ -307,6 +319,10 @@ where
307319
V: TaskInput + 'static,
308320
{
309321
async fn resolve_input(&self) -> Result<Self> {
322+
// Early return for empty map avoids async state machine overhead
323+
if self.is_empty() {
324+
return Ok(Self::default());
325+
}
310326
let mut new_entries = Vec::with_capacity(self.len());
311327
for (k, v) in self {
312328
new_entries.push((
@@ -334,6 +350,10 @@ where
334350
T: TaskInput + Ord + 'static,
335351
{
336352
async fn resolve_input(&self) -> Result<Self> {
353+
// Early return for empty set avoids async state machine overhead
354+
if self.is_empty() {
355+
return Ok(Self::default());
356+
}
337357
let mut new_set = Vec::with_capacity(self.len());
338358
for value in self {
339359
new_set.push(TaskInput::resolve_input(value).await?);

0 commit comments

Comments
 (0)