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.
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.
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))
endmax_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
endAn 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.
- if max_valid2 ≠ typemax(UInt) && invalidations !== nothing
+ if max_valid2 < validation_world && invalidations !== nothingThis 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.)
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
endusing 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.
--trace-compile=stderr: noA.checkcompile at load or first call — while callingcheckon a fresh, unprecompiled type does emit one (positive control). The precompiled code is reused as-is.- The
check(::Prob)CodeInstance after load:has_inferred=true,invokepointer set,max_world=typemax— valid native code, promoted (min_world = require-world), not regenerated. - 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), allinsert_backedges_calleeagainst the same promoted CI, and zeroverify_methodsentries.
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.
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.
Qualify/splice the reference so no implicit binding is involved:
return :($(nothing)), Core.nothing, or Base.getproperty(...) for calls.