mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
f0c1ffc44e
## 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 -->
341 lines
9.7 KiB
Python
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()
|