Skip to content

Instantly share code, notes, and snippets.

@KristofferC
Created July 8, 2026 20:50
Show Gist options
  • Select an option

  • Save KristofferC/006e383c2cd6a6171e667fc9d47e8aec to your computer and use it in GitHub Desktop.

Select an option

Save KristofferC/006e383c2cd6a6171e667fc9d47e8aec to your computer and use it in GitHub Desktop.

verify_method logs phantom insert_backedges_callee invalidations for CodeInstances that are then promoted (wrong sentinel in the emit guard)

cc @topolarity @Keno — load-time backedge verification / invalidation logging on 1.12 and 1.14-DEV.

TL;DR

During package load, verify_method pushes an insert_backedges_callee entry to the invalidation log for any edge whose per-edge max_valid2 ≠ typemax. But the neutral (unconstrained) value inside verify_method is validation_world, not typemax. So an edge that constrains nothing — e.g. a Core.Binding edge to a benign guard/implicit binding (nothing, getproperty, +, …) — logs a phantom invalidation, even though the CodeInstance is immediately promoted and kept (never recompiled).

@snoop_invalidations then renders these as scary rebinding Core.Binding(...) invalidated trees that have zero runtime cost. This is a common false positive in the SciML stack (any @generated function whose body emits a bare Base/Core global and is specialized in a downstream package).

One-line fix (see below): tighten the emit guard to max_valid2 < validation_world.

The bug

base/staticdata.jl (1.12) / Compiler/src/reinfer.jl (1.14), in verify_method:

local minworld::UInt, maxworld::UInt = Base.get_require_world(), validation_world
# ...per-edge loop...
elseif edge isa Core.Binding
    j += 1
    min_valid2 = minworld
    max_valid2 = maxworld            # == validation_world for a benign guard binding
    if !binding_was_invalidated(edge)
        if isdefined(edge, :partitions)      # guard binding: no partitions -> stays validation_world
            min_valid2 = edge.partitions.min_world
            max_valid2 = edge.partitions.max_world
        end
    else
        min_valid2 = 1; max_valid2 = 0
    end
# ...
    if max_valid2  typemax(UInt) && invalidations !== nothing     # <-- wrong sentinel
        push!(invalidations, edge, "insert_backedges_callee", codeinst, copy(matches))
    end

max_valid2 == validation_world is the neutral "unconstrained" result — the CI is valid up to the current world and gets promoted to typemax right after:

if maxworld == validation_world && validation_world == get_world_counter()
    Compiler.store_backedges(child, child.edges)     # promote & keep
end
# ...
if invalidations !== nothing && maxworld < validation_world
    push!(invalidations, child, "verify_methods", cause)   # the REAL invalidation signal
end

An edge with max_valid2 == validation_world is always on the promote path (line maxworld == validation_world) and can never produce a real verify_methods invalidation (which requires maxworld < validation_world). So logging it is never useful — there is no invalidation for it to attribute.

Fix

-   if max_valid2  typemax(UInt) && invalidations !== nothing
+   if max_valid2 < validation_world && invalidations !== nothing

This aligns the emit guard with the loop's actual neutral value. It keeps every genuinely-bounding edge (the useful attributions that precede a real verify_methods invalidation) and drops exactly the phantom == validation_world ones. (typemax cannot be < validation_world, so fully-valid edges still don't log.)

Repro (1.12.6 and 1.14.0-DEV.2406)

Package A has a @generated function whose body contains a bare nothing, and never compiles it itself, so A.nothing stays a guard (no partition) in A's image. Package B owns the concrete type and compiles the specialization into its image.

# A/src/A.jl
module A
@generated function check(x::T) where {T}
    return :(nothing)        # bare `nothing` -> implicit A.nothing guard binding
end
end

# B/src/B.jl  (deps = A)
module B
using A
struct Prob; a::Int; end
_wl() = (A.check(Prob(1)); nothing)
_wl()                        # compiles check(::Prob) into B's image
end
using SnoopCompileCore
invs = @snoop_invalidations begin; using A; using B; end
using SnoopCompile
invalidation_trees(invs)
rebinding Core.Binding(:(A.nothing), #undef, #undef, #undef, 0x08) invalidated:
   mt_backedges: 1: signature A.nothing triggered MethodInstance for A.check(::B.Prob) (0 children)

Self-contained script: generated_rebinding_repro.jl.

Evidence it is phantom (no recompilation)

  1. --trace-compile=stderr: no A.check compile at load or first call — while calling check on a fresh, unprecompiled type does emit one (positive control). The precompiled code is reused as-is.
  2. The check(::Prob) CodeInstance after load: has_inferred=true, invoke pointer set, max_world=typemax — valid native code, promoted (min_world = require-world), not regenerated.
  3. Raw log (via Base.StaticData.debug_method_invalidation / the edge log): with a non-foldable body it gains one entry per bare global in the body (+ * fieldcount === nothing Core - → 28 entries), all insert_backedges_callee against the same promoted CI, and zero verify_methods entries.

Downstream note (SnoopCompile)

SnoopCompile.invalidation_trees (src/invalidations.jl:466) also renders every insert_backedges_callee entry as a standalone :rebinding tree without requiring a matching verify_methods closure, so it surfaces these even if Julia keeps emitting them. The Julia-side guard fix removes them at the source; a defensive SnoopCompile filter (only show CIs that also appear in a verify_methods invalidation) would help older Julia versions.

Real-world instances (SciML stack)

rebinding Core.Binding(:(ConstructionBase.nothing), ...) invalidated:
  mt_backedges: 1: ConstructionBase.check_patch_fields_exist(::NonlinearProblem{...}, ::@NamedTuple{p::Float64})

rebinding Core.Binding(:(NonlinearSolveBase.Utils.getproperty), ...) invalidated:
  mt_backedges: 1: NonlinearSolveBase.Utils.safe_getproperty(::NewtonDescent{Nothing}, ::Val{:linsolve})

Both are @generated functions emitting a bare nothing / getproperty, specialized on types owned by downstream packages. All (0 children) leaves — consistent with promotion, not recompilation.

Package-side workaround (removes the noise pre-fix; no perf change)

Qualify/splice the reference so no implicit binding is involved: return :($(nothing)), Core.nothing, or Base.getproperty(...) for calls.

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