Skip to content

Forward-mode EnzymeNoTypeError on Float32 mutable-struct GC zero-init: wide store i64 0 straddles a Float32 field and adjacent Bools (OrdinaryDiffEq _ode_init) #3276

Description

@ChrisRackauckas-Claude

Summary

Forward-mode differentiation (with runtime activity) of a Float32 computation that constructs a large heterogeneous mutable struct fails type analysis on the struct's GC zero-initialization:

EnzymeNoTypeError: Enzyme cannot statically prove the type of a value being differentiated ...
Cannot deduce single type of store   store i64 0, i64 addrspace(11)* %503, align 8,
  {[0]:Float@float, [4]:Integer, [5]:Integer, [6]:Integer, [7]:Integer} size: 8

The offending store i64 0 is the zero-init of a mutable-struct allocation whose fields include an 8-byte word laid out as a Float32 (bytes 0–3) immediately followed by four Bools (bytes 4–7). Enzyme's type analysis has already established [0:4) as Float and [4:8) as Integer from the individual field accesses, so the single wide 8-byte zeroing store i64 0 over that word can't be assigned one type.

It is Float32-specific: with Float64 the same field occupies its own 8-byte word (the Bools move to the next word), so no zeroing store straddles a float/int boundary and forward mode succeeds.

Reproduction

I was not able to reduce this to a dependency-free Enzyme-only MWE (see "Reduction attempts" below), so this uses OrdinaryDiffEq — but the repro is small (2 imports, plain forward mode, no ODE step is even taken — the failure is entirely in the integrator construction, init):

import OrdinaryDiffEq as ODE
import Enzyme

f(u, p, t) = -p[1] .* u
u0    = Float32[1.0]
tspan = (0.0f0, 1.0f0)

function initcost(p)
    prob  = ODE.ODEProblem(f, u0, tspan, p)
    integ = ODE.init(prob, ODE.Tsit5())
    integ.dt
end

RA_F = Enzyme.set_runtime_activity(Enzyme.Forward)
Enzyme.autodiff(RA_F, Enzyme.Const(initcost), Enzyme.Duplicated(Float32[0.5], Float32[1.0]))

Result:

EnzymeNoTypeError: ... Cannot deduce single type of store   store i64 0, ...
  {[0]:Float@float, [4]:Integer, [5]:Integer, [6]:Integer, [7]:Integer} size: 8
 [1] ODEIntegrator   @ OrdinaryDiffEqCore/src/integrators/type.jl:130
 [2] #_ode_init#81    @ OrdinaryDiffEqCore/src/solve.jl:695

Float64 control — works (correct derivative), same code with u0=[1.0], tspan=(0.0,1.0), p=[0.5]:

initcost(p) = ODE.init(ODE.ODEProblem((u,p,t)->-p[1].*u, [1.0], (0.0,1.0), p), ODE.Tsit5()).dt
Enzyme.autodiff(RA_F, Enzyme.Const(initcost), Enzyme.Duplicated([0.5], [1.0]))
# @NamedTuple{1}((-0.045957120113090597,))

Root cause (field-level)

ODEIntegrator is a mutable struct with many fields, several of them pointers/heap references — so Julia zero-initializes the whole allocation (GC safety) before writing fields, via wide integer stores. For the Float32 problem the concrete layout contains this 8-byte word:

offset field type
264 last_event_error Float32
268 accept_step Bool
269 isout Bool
270 reeval_fsal Bool
271 derivative_discontinuity Bool

That word is exactly {[0]:Float@float, [4]:Integer, [5]:Integer, [6]:Integer, [7]:Integer} — matching the error signature byte-for-byte. The store i64 0 zeroing this word can't be typed as either float or int.

With Float64, last_event_error is 8 bytes and 8-aligned, so it occupies its own word and the zeroing store over it is unambiguously Float — hence forward mode succeeds.

Reduction attempts (why no dependency-free MWE)

I tried ~20 Enzyme-only reductions of "forward-mode over a mutable struct whose GC zero-init emits a wide store i64 0 over a Float32-then-4×Bool word," including:

  • the exact {ptr::Vector, x::Float32, b1::Bool, b2::Bool, b3::Bool, b4::Bool} layout;
  • large (un-SROA-able) structs padded with NTuple{32,Float64} and pointer / #undef-able Any fields (to force the zero-init memset);
  • forcing the allocation to escape via a global Ref{Any} sink;
  • type-unstable / fully-generic (::DataType) construction so it goes through the runtime jl_new_structv path.

All return the correct derivative — the isolated structs either get SROA'd (so no zeroing store exists) or Enzyme types the wide store fine. The real trigger seems to need the specific ~600-byte, deeply type-unstable ODEIntegrator construction that _ode_init produces, which I couldn't reproduce standalone. Happy to keep trying if a dependency-free repro is required — but filing with the minimal SciML-stack repro per maintainer request.

Versions

  • Enzyme v0.13.173
  • OrdinaryDiffEq v7.1.1 / OrdinaryDiffEqCore v4.5.0
  • SciMLBase v3.30.1, DiffEqBase v7.6.0
  • Julia 1.11.9

Context

This is the current (and, on that workload, last-known) blocker for SecondOrder(AutoEnzyme(Forward), AutoEnzyme(Reverse)) forward-over-reverse Hessians/HVPs through SciMLSensitivity + OrdinaryDiffEq, tracked in SciML/SciMLSensitivity.jl#1427 — following the now-resolved deepcopy/make_zero cascade (#3135#3137, #3213, #3246#3251, #3253#3254, #3258#3261). Note the failure is plain forward-mode (not the nesting): Enzyme forward-mode through OrdinaryDiffEq's init is broken for Float32 independent of the second-order question.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions