Skip to content

[vm/aot] IsSmallLeafOrReduction could be cached #64336

Open

Description

@sigurdm

Summary

I asked an llm to make dart build faster. It had an interesting finding:

When AOT-compiling dartdev, FlowGraphInliner spends 6.62s of gen_snapshot's 35.95s building and optimizing callee flow graphs that are then thrown away. A large part of that is one specific rejection path that, unlike the other rejection paths in the inliner, doesn't record the result — so the same callee is rebuilt from kernel at every one of its call sites.

Fixing just that one path removes -29.5% of the failed-inlining time (and makes the snapshot slightly smaller, but that seems accidental).

Background: how a callee gets rejected

CallSiteInliner::TryInliningImpl filters candidates in two stages.

Stage 1 (inliner.cc:1229-1249) runs before any graph exists. It reads the cached Function::optimized_instruction_count() / optimized_call_site_count() and calls ShouldWeInline. Cheap.

Stage 2 runs after BuildGraph (kernel → IL), ComputeSSA, and RunInliningPipeline (ConstantPropagation, ApplyClassIds, TypePropagation, ApplyICData, Canonicalize). Two rejections live here:

  1. CollectGraphInfo + ShouldWeInline (inliner.cc:1457-1493). On failure, if the callee exceeded both thresholds, it calls function.set_is_inlinable(false) — it memoizes, so this path self-heals.
  2. The stricter_heuristic gate, IsSmallLeafOrReduction (inliner.cc:1501-1516). On failure it is not cached.

stricter_heuristic (inliner.cc:1751-1754) is on for AOT, -O2, non-always-inline, nesting_depth == 0 — i.e. most AOT static call sites.

So path 2 is the only rejection in the inliner that pays for a full callee graph and then discards the result.

Measurements

Compiling dartdev's kernel with gen_snapshot (ReleaseX64), via --print_precompiler_timings and --print-inlining-tree:

Late IsSmallLeafOrReduction bailouts 56,215
Distinct functions involved 10,995
Redundant callee graph builds 45,220
Time in failed inlining attempts 6.62 s of 35.95 s gen_snapshot
Time in Inlining overall 17.60 s (47% of gen_snapshot)

Worst repeat offenders:

Callee Rebuilt
_GrowableList. 2,639×
StringBuffer.write 2,118×
_LinkedHashMapMixin.[]= 706×

For scale, Inlining is ~3.5× more expensive than top-level BuildGraph + ComputeSSA + AllocateRegisters + GenerateCode combined, and BuildGraph inside inlining (4.29s) dwarfs BuildGraph for all top-level functions (1.50s).

Why the IsSmallLeafOrReduction verdict can be generalized

IsSmallLeafOrReduction ends in:

if (call_count > 0) {
  return instruction_count <= call_site_instructions;
}
return instruction_count <= FLAG_inlining_small_leaf_size_threshold;

It is monotone increasing in call_site_instructions. At a call site the budget is static_call->ArgumentCount() + 1 + 1, and ArgumentCount() is bounded by arity, so

(IsGeneric() ? 1 : 0) + NumParameters() + 2

is an upper bound over all call sites of the function. A false there implies false everywhere. (Non-static call sites get call_site_instructions = 0, which is smaller still.)

Proposed fix

The LLM also produced a proposed fix: https://dart-review.googlesource.com/c/sdk/+/552660 (currently WIP). It may well not be the right shape — I'm filing this mostly because the underlying observation looks real, and someone who actually owns the inliner should decide what to do about it.

Record the generalized verdict in a spare bit of Function::state_bits_ (IsNotSmallLeafOrReduction) and bail out in stage 1, before BuildGraph.

With three guards:

  • Not recorded, and not applied, when the call site specialized the callee — constant arguments, or omitted optional parameters whose defaults become Constant stubs. Constant folding would make the graph smaller than the one measured. (Stage 1 already discards its cached counts for the same reason.)
  • Not recorded when the scan had to guess kAvgListedMethodSize = 20 for a recognized callee that has not been compiled yet, since that estimate can shrink later and would make inlining depend on compilation order.
  • Read and written only under stricter_heuristic, which implies is_aot(). state_bits_ is a plain non-atomic int8_t, so a JIT write would race SetWasExecuted / SetProhibitsInstructionHoisting; AOT's Precompiler::Iterate is single-threaded.

This is a policy change, not a pure cache

CreateParameterStub (inliner.cc:1123-1144) refines the callee's parameter types with the caller's argument types before the inlining pipeline runs:

param->UpdateType(*CompileType::ComputeRefinedType(param->Type(), argument->Type()));

So a callee can genuinely be a small leaf from caller X and not from caller Y, with no constants involved. The guards cover constant-driven specialization but not type-driven specialization. The patch therefore generalizes the first rejecting call site's verdict to the whole function, and inlines slightly less at the margin.

Empirically that is the good direction — the snapshot gets smaller — but it should be reviewed as a heuristic change.

Instrumented counters confirming the mechanism (dartdev.dill):

Counter baseline patched
set_is_inlinable(false) at the late heuristic 5,393 5,393
Late IsSmallLeafOrReduction rejections 57,636 30,923
New early rejections from the cached bit 0 26,936

The identical 5,393 shows the early bailout does not skip the set_is_inlinable(false) side effect of the late path.

Results

dartdev.dill, ReleaseX64 (gen_snapshot output verified byte-identical across runs):

baseline patched Δ
Failed-inlining time 6.62 s 4.67 s -29.5%
Inlining pass total 17.60 s 15.65 s -11.1%
gen_snapshot total 35.95 s 34.00 s -5.4%
End-to-end dart compile exe 59.28 s 57.33 s -3.3%
Snapshot size 32,881,440 B 32,827,080 B -0.165%

Golem run over dart and dart-aot on linux-x64, linux-armv8, linux-armv8c, linux-armv7hf — patch 20662 against revision 124434 (Google-internal: https://golem.corp.goog/Revision?repository=dart&revision=124434&patch=20662):

  • dart-aot linux-x64 RunTime geomean over 46 suites: -0.38%
  • dart-aot linux-armv8 RunTime geomean over 25 suites: +0.03%
  • Built SDK size: -99,776 B
  • No significant change replicates across architectures.

Minor, same function

IsSmallLeafOrReduction's inlining_depth parameter is dead — declared, never
read. The CL passes /*inlining_depth=*/0 for documentation only.

Activity

  1. added
    area-vmUse area-vm for VM related issues, including code coverage, and the AOT and JIT backends.
    type-performanceIssue relates to performance or code size
    on Sep 21, 2026
  2. biggs0125 commented on Sep 23, 2026

    @biggs0125

    @sigurdm Thanks for the proposed fix. Wondering what effect this would have on the golem benchmarks. Would you mind uploading a patch to golem and posting the results here?

    @alexmarkov pointed out that every time we do these inline checks we have to do the full optimization pipeline to get the size heuristic for the inline target which is context sensitive. It's worth exploring whether we need that to be context sensitive and then if we don't we could get better caching.

  3. added
    P3A lower priority bug or feature request
    triagedIssue has been triaged by sub team
    on Sep 23, 2026
  4. sigurdm commented on Sep 24, 2026

    @sigurdm
    ContributorAuthor

    Thanks! The Golem numbers are in the issue body under "Results" — patch 20662 against revision 124434: https://golem.corp.goog/Revision?repository=dart&revision=124434&patch=20662

    Short version:

    • dart-aot linux-x64 RunTime geomean over 46 suites: -0.38%
    • dart-aot linux-armv8 RunTime geomean over 25 suites: +0.03%
    • Built SDK size: -99,776 B
    • 5 microbenchmarks cross |sig| >= 1.01 on x64, none of them replicate on ARM.

    On context sensitivity: I measured it. On an instrumented gen_snapshot (no caching change), every stage-2 inlining attempt (i.e. every time the callee graph is built and run through the inlining pipeline) logs the callee, whether the call site passed constants, whether any parameter type was narrowed by the caller's argument type, and the sizes/verdicts computed on that graph. Compiling dartdev:

    • 226,089 stage-2 attempts over 28,699 callees. 37% have no caller info at all, 40% only have narrowed parameter types, 23% have constant arguments.
    • ShouldWeInline at stage 2: for non-constant call sites CollectGraphInfo already reuses the counts cached from whichever graph was measured first. That's a different number from the graph in hand at 33.8% of those sites, but it changes the verdict at only 56 of 174,723 (0.03%). Per callee, the verdict on each site's own graph varies for 39 of 12,627 multi-site callees (30 because of constants, 9 because of types alone).
    • IsSmallLeafOrReduction: 71,532 attempts get as far as this check; 82% fail it. The verdict varies across call sites for 113 of 5,237 multi-site callees: 84 because of constants, 29 because of argument types alone, 0 without any caller info. Compared to the same callee's no-caller-info verdict, 99.4% of sites agree. In the remaining 0.6% (270 sites) caller context makes it pass. It never makes it fail.
    • The call-site budget (argument count) changes the small-leaf verdict at 0.7% of sites.

    So context sensitivity changes very few decisions, and constants account for about three quarters of those. Type-only specialization does matter for a handful of callees. The biggest is _GrowableList.of: among its call sites without constants (all of which have narrowed parameter types), 64 pass the small-leaf check and 392 fail it, so the answer depends on what the caller knows about the argument. A size measured once without caller context, re-measured only when constants are passed, would probably lose those 64.

    That also showed that the first version of the CL wasn't quite right: it recorded the verdict from type-refined sites too, so it could lose some of those _GrowableList.of inlines depending on which caller is seen first. I've updated the CL (patch set 3) to only record it from sites where neither constants nor narrowed parameter types are involved. dartdev, median of 3 interleaved runs:

    failed-inlining time snapshot size
    baseline 7.30 s 32,930,264 B
    first version of the CL 4.92 s (-33%) 32,875,640 B (-0.166%)
    current CL 5.67 s (-22%) 32,924,968 B (-0.016%)

    So the stricter version keeps about two thirds of the compile-time win, and most of the size change goes away. That suggests most of the size change came from the type-refined sites. Whole-gen_snapshot numbers are too noisy on this machine to separate the two variants. The Golem numbers above are for the first version. I've started a Golem run for the current one against the same revision, and will post the results when it's done: https://golem.corp.goog/Revision?repository=dart&revision=124434&patch=20741

    Instrumentation diff, analysis script and steps to reproduce: https://gist.github.com/sigurdm/a16c768170e871552a4e604b43965f72

  5. rakudrama commented on Sep 24, 2026

    @rakudrama
    Member

    I'm not so sure about the 'self-heals' comment.
    Inlining decisions are compilation-order dependent (in my opinion a bug).
    See: #48207

  6. sigurdm commented on Sep 28, 2026

    @sigurdm
    ContributorAuthor

    @rakudrama Good point — by "self-heals" I only meant in terms of compile-time work (it stops rebuilding the callee graph over and over), not that the resulting decisions are order-independent.

    Tracing the two examples in #48207, the second one is actually the exact same CollectGraphInfo blind spot that showed up in the dartdev numbers above:

    1. Example 1 (categorize(1) vs categorize(xx)): When categorize(xx) fails ShouldWeInline on both thresholds, set_is_inlinable(false) clears kIsInlinableBit, which is checked at the very top of TryInliningImpl before CountConstants. So one unspecialized call permanently blocks later constant-argument calls (categorize(2) and demo1's categorize(1)). (IsNotSmallLeafOrReduction in the CL is only consulted when constant_arg_count == 0, so it avoids this.)
    2. Example 2 (categorize(g++) where g is int): Here constants_count == 0, so CollectGraphInfo treats the call as unspecialized even though CreateParameterStub narrowed o from Object? to int and folded the graph to size = 1, call_sites = 0. CollectGraphInfo caches (1, 0) on categorize as its optimized_instruction_count / optimized_call_site_count.
      • Two lines later in demo2, categorize(xx) (Object?, actual graph size 54, call sites 8) calls CollectGraphInfo(..., force=false), sees optimized_instruction_count() != 0, and returns the cached (1, 0) instead of measuring the 54-instruction graph in hand (which is why it passes ShouldWeInline and only bails out at IsSmallLeafOrReduction).
      • Then categorize itself is compiled as a top-level function between demo2 and demo1, and FinalizeGraph calls CollectGraphInfo(..., force=true) on the unspecialized graph, overwriting the cached counts with (54, 8). When demo1 runs afterward, categorize(g++) (constant_arg_count == 0) reads (54, 8) at the Stage 1 early heuristic and bails out before building the graph.
      • That is also why CollectGraphInfo's cached counts disagreed with the actual graph in hand at 33.8% of non-constant call sites in the dartdev trace. Passing constants_count > 0 || param_types_refined_ into CollectGraphInfo (so type-refined calls don't overwrite the unspecialized counts) would avoid poisoning optimized_instruction_count on categorize(g++).

    Note that while guarding SetIsNotSmallLeafOrReduction(true) with constants_count == 0 && !param_types_refined_ avoids poisoning the cache from a specialized call, reading the bit at Stage 1 (constant_arg_count == 0) still leaves a compilation-order dependence for the 29 callees in dartdev (and categorize in #48207) where type refinement alone flips the verdict: if an unrefined categorize(xx) at depth >= 2 runs before categorize(g++), the latter will bail out at Stage 1 unless Stage 1 also checks whether any call-site argument has a narrower static type than the declared parameter type.


    Golem results for Patch Set 4 (patch 20741)

    Guarding the cache write with constants_count == 0 && !param_types_refined_ eliminates almost all of the AOT snapshot size difference on SDKArtifactSizes compared to the unguarded version (patch 20662):

    Benchmark (SDKArtifactSizes) Baseline (r124434) Patch 20741 (!param_types_refined_) Diff (Patch 20741) Previous Diff (Patch 20662)
    analysis_server 84,286,784 B 84,286,464 B -320 B (-0.0004%) -197,360 B (-0.234%)
    dds_aot 5,106,984 B 5,106,576 B -408 B (-0.0080%) -24,616 B (-0.482%)
    dartdevc 62,463,040 B 62,463,040 B 0 B (0.0000%) -64,464 B (-0.103%)
    dart 4,821,168 B 4,821,168 B 0 B (0.0000%) 0 B
    dartaotruntime 5,699,488 B 5,699,488 B 0 B (0.0000%) 0 B

    dart-aot RunTime geomeans are within noise (+0.13% across 45 suites on linux-x64, +0.53% across 18 suites on linux-armv8).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3A lower priority bug or feature requestarea-vmUse area-vm for VM related issues, including code coverage, and the AOT and JIT backends.triagedIssue has been triaged by sub teamtype-performanceIssue relates to performance or code size

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions