Files
vercel__next.js/turbopack/scripts/analyze_cache_effectiveness.py
Luke Sandberg f0c1ffc44e Remove ineffective turbo-tasks (#91341)
## Remove ineffective turbo-tasks

Identifies and removes turbo-tasks functions where the task overhead exceeds the value they provide. Each turbo-task carries ~4-6μs execution overhead per miss and ~200-500ns per cache hit, plus allocations and bookkeeping.

### What?

Removes 22 `#[turbo_tasks::function]` implementations across resolve plugins, chunk items, and resolve-result helpers — converting them to plain methods or inlining their work. Changes fall into a few buckets:

- **ResolvePlugin condition handling** (`AfterResolvePluginCondition::matches`, `BeforeResolvePluginCondition::matches`, `after_resolve_condition`, `before_resolve_condition`): conditions now store the resolved `Glob` as a `ReadRef<Glob>` on the plugin struct at construction, so `matches` is a pure sync function and the per-plugin `*_resolve_condition` getters are trivial field reads (no longer turbo-tasks). The `after_resolve` / `before_resolve` hooks themselves stay as `#[turbo_tasks::function]` — they synthesize virtual sources/modules and need memoization on `(self, lookup_path, reference_type, request)` to avoid distinct cells producing duplicate module-graph idents.
    - The basic theory here is that the right level of caching is at `resolve` and at the hook bodies themselves, not the conditions or condition getters.
    - `AfterResolvePluginCondition` and `BeforeResolvePluginCondition` are marked `serialization = "none"` because `ReadRef` cannot be persisted; plugin construction is cheap enough to re-derive on restore.
- **ChunkItem trait methods** (`chunking_context`, `ty`, `content_with_async_module_info`): returned constants or simple field reads, zero cache hits and no `.await` calls (no invalidation value).
- **ResolveResult / ModuleResolveResult helpers** (`primary_modules`, `first_module`, `first_source`, `primary_sources`, `is_unresolvable`, `primary_output_assets`): simple iterators over already-resolved data; converted to plain methods. Added a `Duplicate(usize)` variant to `ModuleResolveResultItem` to handle dedup at construction time instead of in a separate task.
    - The basic idea here is that it is reasonable to consume `ResolveResult/ModuleResolveResult` monolithically, and we get little to no benefit from fine grained access. e.g. `is_unresolved()` in theory that is a valuable turbotask, but since it rarely changes but generally if we change how we resolve an import then we have to regenerate code, so saving a few boolean conditions is unlikely to be very valuable.
- Misc: `EcmascriptModuleAsset::analyze`, `is_types_resolving_enabled`, `next_server::resolve::condition`.

### Impact (vercel-site build, dev first-compile)

| Metric | Before | After | Δ |
|---|---:|---:|---:|
| Total cache hits | 30,885,827 | 29,201,314 | −1,684,513 |
| Total cache misses | 6,473,123 | 5,953,626 | **−519,497** |
| Overall hit rate | 82.67% | 83.06% | +0.39 pp |
| Registered task functions | 1,294 | 1,272 | −22 |

The 22 removed tasks were collectively responsible for ~519K misses per build — each miss previously paying the full execution overhead. Most of the work from `EcmascriptModuleAsset::analyze` naturally migrated into `analyze_ecmascript_module` (the task it was wrapping; +129K hits there).

### On-disk cache size (persistent caching)

Each removed task also stops allocating cache cells on disk. Measured on the same vercel-site build with `.next/cache/turbopack` (persistent cache enabled):

| | Size |
|---|---:|
| canary | 2.56 GiB |
| this branch | 2.46 GiB |
| **saved** | **~100 MiB (−3.81%)** |

### Build-time wall clock and peak memory

Ran `pnpm next build --experimental-build-mode=compile` 5 times on each branch

**Peak RSS — clear reduction:**

| | canary | branch | Δ |
|---|---:|---:|---:|
| min | 19.18 GiB | 18.94 GiB | |
| **median** | **19.22 GiB** | **19.01 GiB** | **−217 MiB (−1.10%)** |
| mean | 19.21 GiB | 19.02 GiB | −199 MiB (−1.01%) |
| max | 19.23 GiB | 19.13 GiB | |

Every branch run has lower RSS than every canary run — the distributions don't overlap. Welch's t = −6.03.

**Wall time — no measurable change:**

| | canary | branch | Δ |
|---|---:|---:|---:|
| min | 62.03s | 60.78s | |
| **median** | **62.61s** | **62.65s** | **+0.04s (+0.06%)** |
| mean | 62.83s | 63.80s | +0.96s (+1.53%) |
| max | 64.25s | 68.23s | |
| stddev | 0.84s | 3.42s | |

Median is flat. The mean difference is within noise (Welch's t = +0.61, n = 5). Branch run-to-run variance is higher — one 68.23s outlier pulls the mean up — so this is neither a regression nor a measurable speedup at this sample size.

<!-- NEXT_JS_LLM_PR -->
2026-05-08 14:21:52 -07:00

341 lines
9.7 KiB
Python

#!/usr/bin/env python3
"""
Cache Effectiveness Analysis Script
This script analyzes task statistics to identify which tasks are not getting
significant benefit from caching and would be candidates for removing the
caching layer.
To use this script, run a build with `NEXT_TURBOPACK_TASK_STATISTICS=path/to/stats.json` set
Then run this script with the path to the stats.json file to get a report on cache effectiveness.
The JSON format contains entries like:
{ "task_name": { "cache_hit": N, "cache_miss": N } }
Usage:
analyze_cache_effectiveness.py <stats.json>
analyze_cache_effectiveness.py --diff <before.json> <after.json> [--top N]
In --diff mode, the script reports the tasks whose hits or misses changed the
most between the two runs — useful for evaluating the impact of removing or
adding `turbo_tasks` caching on specific functions.
"""
import argparse
import json
import sys
from typing import Dict, List
from dataclasses import dataclass
@dataclass
class TaskStats:
name: str
cache_hit: int
cache_miss: int
@property
def total_operations(self) -> int:
return self.cache_hit + self.cache_miss
@property
def cache_hit_rate(self) -> float:
if self.total_operations == 0:
return 0.0
return self.cache_hit / self.total_operations
def load_task_stats(file_path: str) -> List[TaskStats]:
"""Load and parse task statistics from JSON file."""
with open(file_path, 'r') as f:
data = json.load(f)
tasks = []
for task_name, stats in data.items():
task = TaskStats(
name=task_name,
cache_hit=stats["cache_hit"],
cache_miss=stats["cache_miss"],
)
tasks.append(task)
return tasks
def load_task_stats_map(file_path: str) -> Dict[str, TaskStats]:
return {t.name: t for t in load_task_stats(file_path)}
def analyze_tasks(tasks: List[TaskStats]) -> List[TaskStats]:
"""Analyze all tasks and return sorted by wasted cache overhead.
Tasks with the most wasted overhead are ranked first. Wasted overhead is
estimated as cache misses (each miss pays lookup cost but gets no benefit)
plus cache hits weighted by their relative cheapness compared to a miss.
In practice this sorts by: most cache misses first, breaking ties by lower
hit rate.
"""
# Sort by cache_miss descending, then by hit rate ascending
tasks.sort(key=lambda t: (-t.cache_miss, t.cache_hit_rate))
return tasks
def print_analysis(tasks: List[TaskStats]):
"""Print the analysis results."""
print("Tasks ranked by cache effectiveness (worst first)")
print()
if not tasks:
print("No tasks found.")
return
# Print header
header = (f"{'Hit Rate':<10} {'Hits':<10} {'Misses':<10} "
f"{'Total':<10} {'Task Name'}")
print(header)
print("-" * len(header))
total_hits = 0
total_misses = 0
low_hit_rate_count = 0
# Print results
for task in tasks:
hit_rate_str = f"{task.cache_hit_rate:.1%}"
hits_str = f"{task.cache_hit:,}"
misses_str = f"{task.cache_miss:,}"
total_str = f"{task.total_operations:,}"
print(f"{hit_rate_str:<10} {hits_str:<10} {misses_str:<10} "
f"{total_str:<10} {task.name}")
total_hits += task.cache_hit
total_misses += task.cache_miss
if task.cache_hit_rate < 0.5:
low_hit_rate_count += 1
total_ops = total_hits + total_misses
overall_hit_rate = total_hits / total_ops if total_ops > 0 else 0.0
# Print summary
print()
print(f"Total functions: {len(tasks)}")
print(f"Total cache misses: {total_misses:,}")
print(f"Overall cache hit rate: {overall_hit_rate:.1%} ({total_hits:,} hits / {total_ops:,} total)")
print(f"Tasks with <50% hit rate: {low_hit_rate_count}")
@dataclass
class TaskDiff:
name: str
before_hit: int
after_hit: int
before_miss: int
after_miss: int
@property
def delta_hit(self) -> int:
return self.after_hit - self.before_hit
@property
def delta_miss(self) -> int:
return self.after_miss - self.before_miss
@staticmethod
def _rate(hit: int, miss: int) -> float:
total = hit + miss
return hit / total if total > 0 else 0.0
@property
def before_rate(self) -> float:
return self._rate(self.before_hit, self.before_miss)
@property
def after_rate(self) -> float:
return self._rate(self.after_hit, self.after_miss)
@property
def delta_rate(self) -> float:
return self.after_rate - self.before_rate
def compute_diff(
before: Dict[str, TaskStats], after: Dict[str, TaskStats]
) -> List[TaskDiff]:
names = set(before) | set(after)
diffs = []
zero = TaskStats(name="", cache_hit=0, cache_miss=0)
for name in names:
b = before.get(name, zero)
a = after.get(name, zero)
diffs.append(
TaskDiff(
name=name,
before_hit=b.cache_hit,
after_hit=a.cache_hit,
before_miss=b.cache_miss,
after_miss=a.cache_miss,
)
)
return diffs
def _print_diff_section(
title: str, diffs: List[TaskDiff], key, top: int, reverse: bool
):
print("=" * 100)
print(title)
print("=" * 100)
ordered = sorted(diffs, key=key, reverse=reverse)
header = (
f"{'Δhit':>12} {'Δmiss':>12} {'hit b→a':>22} {'miss b→a':>22} "
f"{'rate b→a':>18} Task"
)
print(header)
print("-" * len(header))
shown = 0
for d in ordered:
# Stop once the signed delta crosses zero in the direction we care about.
v = key(d)
if reverse and v <= 0:
break
if not reverse and v >= 0:
break
hit_str = f"{d.before_hit:,}{d.after_hit:,}"
miss_str = f"{d.before_miss:,}{d.after_miss:,}"
rate_str = f"{d.before_rate:.0%}{d.after_rate:.0%}"
print(
f"{d.delta_hit:>+12,} {d.delta_miss:>+12,} "
f"{hit_str:>22} {miss_str:>22} {rate_str:>18} {d.name}"
)
shown += 1
if shown >= top:
break
if shown == 0:
print("(none)")
print()
def print_diff(
before: Dict[str, TaskStats], after: Dict[str, TaskStats], top: int
):
diffs = compute_diff(before, after)
before_hits = sum(t.cache_hit for t in before.values())
after_hits = sum(t.cache_hit for t in after.values())
before_misses = sum(t.cache_miss for t in before.values())
after_misses = sum(t.cache_miss for t in after.values())
before_total = before_hits + before_misses
after_total = after_hits + after_misses
before_rate = before_hits / before_total if before_total > 0 else 0.0
after_rate = after_hits / after_total if after_total > 0 else 0.0
only_before = set(before) - set(after)
only_after = set(after) - set(before)
print("Cache statistics diff (before → after)")
print()
print(
f"Hits: {before_hits:>12,}{after_hits:>12,} "
f"({after_hits - before_hits:+,})"
)
print(
f"Misses: {before_misses:>12,}{after_misses:>12,} "
f"({after_misses - before_misses:+,})"
)
print(
f"Hit rate: {before_rate:>11.2%}{after_rate:>11.2%} "
f"({(after_rate - before_rate) * 100:+.2f} pp)"
)
print(
f"Tasks: {len(before):>12,}{len(after):>12,} "
f"(only in before: {len(only_before)}, only in after: {len(only_after)})"
)
print()
_print_diff_section(
f"Top {top} INCREASES IN HITS (after > before)",
diffs,
key=lambda d: d.delta_hit,
top=top,
reverse=True,
)
_print_diff_section(
f"Top {top} INCREASES IN MISSES (after > before)",
diffs,
key=lambda d: d.delta_miss,
top=top,
reverse=True,
)
_print_diff_section(
f"Top {top} DECREASES IN HITS (after < before)",
diffs,
key=lambda d: d.delta_hit,
top=top,
reverse=False,
)
_print_diff_section(
f"Top {top} DECREASES IN MISSES (after < before)",
diffs,
key=lambda d: d.delta_miss,
top=top,
reverse=False,
)
def main():
parser = argparse.ArgumentParser(
description=(
"Analyze turbo_tasks cache effectiveness, or diff two stats files "
"to see which tasks gained or lost hits/misses."
),
)
parser.add_argument(
"--diff",
action="store_true",
help="Diff mode: compare two stats files and report largest changes.",
)
parser.add_argument(
"--top",
type=int,
default=25,
help="Number of entries to show per section in --diff mode (default: 25).",
)
parser.add_argument(
"files",
nargs="+",
help=(
"Path to stats JSON. In default mode, one file. "
"In --diff mode, two files: <before> <after>."
),
)
args = parser.parse_args()
try:
if args.diff:
if len(args.files) != 2:
parser.error("--diff requires exactly two files: <before> <after>")
before = load_task_stats_map(args.files[0])
after = load_task_stats_map(args.files[1])
print_diff(before, after, args.top)
else:
if len(args.files) != 1:
parser.error("default mode requires exactly one stats file")
tasks = load_task_stats(args.files[0])
tasks = analyze_tasks(tasks)
print_analysis(tasks)
except FileNotFoundError as e:
print(f"Error: File not found: {e.filename}")
sys.exit(1)
except json.JSONDecodeError as e:
print(f"Error parsing JSON: {e}")
sys.exit(1)
if __name__ == "__main__":
main()