Changing struct fields to a different sub-type during run time

Hello everyone, I am working on an aerospace system and as I am developing more complex use cases, I need to change fields on structs to a new type during a simulation. For example, when a spacecraft leaves the Earth system, we use a different time system to model time. We also use different coordinate systems and state representations as a mission evolves. So, immutable concrete types in fields are problematic. I don’t need to do this often (relatively speaking to say an integration step size), but it does need to happen at certain points in a simulation. Reconstructing the whole spacecraft struct causes me problems because it breaks references to the struct used in the sim elsewhere.

I know using abstract types here causes major performance problems so I have avoided that. The solution I am considering is to use concrete “wrapper structs” and parameterize the state and time type inside the wrappers for performance and type flexibility. If I need to change the state or time type, I reconstruct the wrapper which is concrete and since the wrapper internals are parameterized, it’s fields are also concrete.

To hide this from users (who are aerospace engineers, not Julia experts), I would overload get/set property so users only work with the concrete time and state types and the nested structure pattern is hidden behind the API.

Here is working code. Is there a better way to do this? Feedback, thoughts?

Thanks in advance for any help here!!

# Abstract interfaces
abstract type AbstractState end
abstract type AbstractTime end

# Concrete types (there would be many more, these are examples)
struct CartesianState <: AbstractState
    rv::Vector
end

struct UTC <: AbstractTime
    seconds::Float64
end

struct TDB <: AbstractTime
    seconds::Float64
end

# Parametric wrappers for time and state
struct OrbitState{T<:AbstractState}
    state::T
end

struct SpacecraftTime{T<:AbstractTime}
    time::T
end

# Spacecraft stuct
mutable struct Spacecraft
    state::OrbitState
    time::SpacecraftTime
end

# Outer constructor
function Spacecraft(state::AbstractState, time::AbstractTime)
    Spacecraft(OrbitState(state), SpacecraftTime(time))
end

# Override get/set property for Spacecraft so wrappers and type management are hidden
function Base.getproperty(s::Spacecraft, sym::Symbol)
    if sym === :state
        return getfield(s, :state).state
    elseif sym === :time
        return getfield(s, :time).time
    end
end

function Base.setproperty!(s::Spacecraft, sym::Symbol, val)
    if sym === :time && val isa AbstractTime
        return setfield!(s, :time, SpacecraftTime(val))
    elseif sym === :state && val isa AbstractState
        return setfield!(s, :state, OrbitState(val))
    else
        return setfield!(s, sym, val)
    end
end

# EXAMPLE USAGE HERE

sc = Spacecraft(CartesianState([10.0e6,0.0,0,0,0,0,7.8,15.0,0]), UTC(123.456))

# Propagate to Earth Sphere of influence happens here

# Change time to TDB, this would happen in the internals not at user level, just showing what needs to happen
sc.time = TDB(4.5)  # Time was UTC type, now it is TDB type

# Propagate to Moon happens here

These fields are still declared with abstract types, and that’s what the compiler sees given the concrete Spacecraft. For example, the compiler can only see getfield(s, :state)::OrbitState in getproperty(s::Spacecraft, :state), so it can infer the return value as ::AbstractState at best, not the likely concrete state::T. Note that @code_warntype sc.state would give you a more pessimistic report that doesn’t consider how the literal :state is specially propagated for getproperty to narrow down the branch. At the moment, it’s not clear how this layer of indirection has any advantage over:

mutable struct SpacecraftDirect
    state::AbstractState
    time::AbstractTime
end

getproperty and setproperty returns instances, which will always have concrete types at runtime i.e. neither typeof(sc.state) nor typeof(sc_direct.state) would ever be abstract. The abstract declared types OrbitState or AbstractState are only exposed to the users if they directly reference them or use reflection functions like fieldtypes(Spacecraft) or fieldtype(SpacecraftDirect, :state).

Without any indication of how these instances are passed into and used in methods, it’s impossible to tell where this compromises performance and how you may limit that. I’d also suggest you vastly change the tags of this question if it will only concern the base type system; there would be no reason to specially categorize this topic for aerospace simulations or imply readers need any such experience to interact.

As Benny remarks, there are still type instability due to abstract fields. I think you are better off with either LightSumTypes.jl or SumTypes.jl. It will look something like this. (I have very limited experience with these packages, but I think this is what you need).

using LightSumTypes: @sumtype

struct UTC
    seconds::Float64
end

struct TDB
    seconds::Float64
end

@sumtype TimeType(UTC, TDB)

mutable struct Spacecraft
    time::TimeType
end

It’s type stable:

function doubletime(sc::Spacecraft)
    2sc.time.seconds
end

julia> sc = Spacecraft(TimeType(UTC(42.17)))
Spacecraft(TimeType(UTC(42.17)))

julia> @code_warntype doubletime(sc)
MethodInstance for doubletime(::Spacecraft)
  from doubletime(sc::Spacecraft) @ Main ~/scratch/foo.jl:17
Arguments
  #self#::Core.Const(Main.doubletime)
  sc::Spacecraft
Body::Float64
1 ─ %1 = Main.:*::Core.Const(*)
│   %2 = Base.getproperty(sc, :time)::TimeType
│   %3 = Base.getproperty(%2, :seconds)::Float64
│   %4 = (%1)(2, %3)::Float64
└──      return %4


julia> sc.time = TimeType(TDB(12.73))
TimeType(TDB(12.73))

julia> @code_warntype doubletime(sc)
MethodInstance for doubletime(::Spacecraft)
  from doubletime(sc::Spacecraft) @ Main ~/scratch/foo.jl:17
Arguments
  #self#::Core.Const(Main.doubletime)
  sc::Spacecraft
Body::Float64
1 ─ %1 = Main.:*::Core.Const(*)
│   %2 = Base.getproperty(sc, :time)::TimeType
│   %3 = Base.getproperty(%2, :seconds)::Float64
│   %4 = (%1)(2, %3)::Float64
└──      return %4

Are you sure that scales to more types? 2sc.time.seconds doesn’t look like an expression that restores type stability. It might just be leveraging Union-splitting for small sets.

julia> mutable struct Spacecraft2
           time::Union{UTC,TDB}
       end

julia> function doubletime(sc::Spacecraft2)
           2sc.time.seconds
       end
doubletime (generic function with 2 methods)

julia> @code_warntype doubletime(Spacecraft2(UTC(42.17)))
MethodInstance for doubletime(::Spacecraft2)
  from doubletime(sc::Spacecraft2) @ Main REPL[8]:1
Arguments
  #self#::Core.Const(Main.doubletime)
  sc::Spacecraft2
Body::Float64
1 ─ %1 = Main.:*::Core.Const(*)
│   %2 = Base.getproperty(sc, :time)::Union{TDB, UTC}
│   %3 = Base.getproperty(%2, :seconds)::Float64
│   %4 = (%1)(2, %3)::Float64
└──      return %4

I’m not sure, but this particular pattern works with large sum types. However, dispatch on variants of TimeType is problematic. So it might be just a lucky happenstance with the compiler that the simple doubletime works. I think the union of bits types in TimeType is inlined (a “tagged Union”), so the compiler notices that .seconds is the same type for all of them. So it probably doesn’t work when the T structs are not of bits type, or are parametric or some such thing.

using LightSumTypes: @sumtype, variant

const N = 50
for i in 1:N
    tsym = Symbol("T",i)
    @eval begin
        struct $tsym
            seconds::Float64
        end
        f(t::$tsym) = t.seconds
    end
end

@eval @sumtype TimeType($((Symbol('T', i) for i in 1:N)...))
f(t::TimeType) = f(variant(t))

mutable struct Spacecraft
    time::TimeType
end

function doubletime(sc::Spacecraft)
    2sc.time.seconds
end

sc = Spacecraft(TimeType(T1(42.17)))
@code_warntype doubletime(sc)
@code_warntype f(sc.time)

yields:

MethodInstance for doubletime(::Spacecraft)
  from doubletime(sc::Spacecraft) @ Main REPL[7]:1
Arguments
  #self#::Core.Const(Main.doubletime)
  sc::Spacecraft
Body::Float64
1 ─ %1 = Main.:*::Core.Const(*)
│   %2 = Base.getproperty(sc, :time)::TimeType
│   %3 = Base.getproperty(%2, :seconds)::Float64
│   %4 = (%1)(2, %3)::Float64
└──      return %4

MethodInstance for f(::TimeType)
  from f(t::TimeType) @ Main REPL[5]:1
Arguments
  #self#::Core.Const(Main.f)
  t::TimeType
Body::Float64
1 ─ %1 = Main.f::Core.Const(Main.f)
│   %2 = Main.variant::Core.Const(LightSumTypes.variant)
│   %3 = (%2)(t)::Union{T1, T10, T11, T12, T13, T14, T15, T16, T17, T18, T19, T2, T20, T21, T22, T23, T24, T25, T26, T27, T28, T29, T3, T30, T31, T32, T33, T34, T35, T36, T37, T38, T39, T4, T40, T41, T42, T43, T44, T45, T46, T47, T48, T49, T5, T50, T6, T7, T8, T9}
│   %4 = (%1)(%3)::Float64
└──      return %4

1 Like

Hmm, this example might work just as well with

mutable struct Spacecraft
    time::Union{T1, T2, T3, T4}
end

However, I think LightSumTypes.jl might do some type assertions here and there to isolate instabilities.

Just to let you know, LightSimTypes.jl has been improved upon by WrappedUnions.jl, so I would suggest using that instead of it. See also the discourse announcement: [ANN]: WrappedUnions.jl: Wrap a Union for enhanced type-stability

2 Likes

That’s what I remembered from another thread. But now that I’m actually trying the package and looking into the source code, it’s type-stable and scales up if every type has seconds::Float64. For a more modest example that exceeds the automatic Union-splitting optimization of 3, getproperty does something like this:

        v = (LightSumTypes).unwrap(sumt)
        if v isa T1
            return (Base).getproperty(v, s)
        elseif v isa T2
            return (Base).getproperty(v, s)
        elseif v isa T3
            return (Base).getproperty(v, s)
        elseif v isa T4
            return (Base).getproperty(v, s)
        elseif v isa T5
            return (Base).getproperty(v, s)
        else
            error("THIS_SHOULD_BE_UNREACHABLE")
        end

So while a normal getproperty call or equivalent structure.property expression for a type-unstable structure::Union{...} would fail, the concrete LightSumType would branch to every specified type and separately call getproperty again. If all those inner getpropertys have the same concrete return type, then the overall getproperty shares the type; if they don’t, then it takes very little variation to lose type stability. Pretty much every fundamental function generated by @sumtype has this branch, and it doesn’t have the extra help of assertions or conversions. The successor (in the sense that both are authored by Tortar) WrappedUnions.@unionsplit puts that branch generation in the user’s hands, which I am guessing would also let the user add convert calls to restore type stability. To manually write the branch’s expressions instead of generating duplicates, you’d need an API like @cases of SumTypes.jl; I mention this because I expect the various time types to typically have different operations, not just Float64 arithmetic.

f surprised me a bit. variant evidently does not reach a stable return type via some large branch; that branch can’t happen at that stage anyway. However, the compiler was still able to spot the 50 f methods for the 50 T#=i=# types all reached seconds::Float64; to my knowledge, that far exceeds the default Union-splitting optimization (3) and inference of multiple applicable methods (4). I don’t understand the compiler much, maybe someone else can comment. EDIT: The limit was removed for linear signatures, whatever that means RFC: inference: remove union-split limit for linear signatures by vtjnash · Pull Request #37378 · JuliaLang/julia · GitHub

A big set of types has other limits however. The Union-annotated field of these approaches are internally implemented like C’s tagged unions if all the types are isbits, but that only goes up to 256 types because the tag is 1 byte. Even if that limit is increased, the parser currently hits a recursion limit for if-elseif branches somewhere between 256 and 65536.

Thanks for all the insightful comments here. I can’t claim to be a total noobie anymore, but I’ve only been working in Julia for about 5 months, so there is still much to learn. Yikes, I did not know this

julia> @code_warntype sc.state

 %5  = Base.getproperty(%4, :state)::AbstractState

I thought I had a fast, elegant, and stable approach, that I could use more broadly. Doh! The main struct here, Spacecraft, will be very, very complex. This is probably 5% of the complexity of that struct when it is done. In other systems I’ve worked in like this the spacecraft object is 10000+ lines of code all by itself, so using composition just helps manage the complexity too).

Replying to other responses here as well: I was trying to keep the question focused, but now I realize I should have mentioned that each of the types like Time and State are fairly complex themselves and why I started with a composition-based design that lets me isolate all that complexity in cleanly encapsulated utilities that can grow and change behind (what I hope is, and why I’m posting here!!) a clean, stable API.

For example, time is not a single Real, so to have one Real and a meta-data field would present some other challenges ( I considered this approach) . To get the required precision (sub nano-second), time is stored as two floats. Also, users can input the time in different formats like this which the Time struct manages:

# Define time in Terrestrial Time (TT) using ISOT time format
t_tt = TerestrialTime("2015-09-21T12:23:12", ISOT())

# Set time with sub-nanosecond precision using two floats day and fraction of day
t_utc = UniversalTime(jd_day, jd_fracday, PrecisionJD())

I’ve spent time off-and-on over a few months now struggling with this because I keep finding designs that solve one set of requirements/interfaces but then others are either really messy, or just broken. It seems to me there is a tension in the language when it comes to performance and the need for generality and dynamic typing, and that is what I am struggling with.

I’ll take a look at WrappedUnions, (could Accessors.jl help ?).

I think I will also just have to benchmark something using this design like Benny suggested because the details matter. Since some of these type changes would only happen a few times in a whole sim, some aspects of the performance hit won’t matter. When updating state during numerical integration in solve() in DifferentialEquations is where the real performance issue would occur. if I call the getproperty(:state) just once at the beggining of integration to get the concrete state struct, then operate directly on that, I would only call the slow interface once. (Am I missing anything there?).

I have a parameterized concrete type design implemented already (and need to extend it, hence this post) and it is benchmarked against C++ now (and faster, cool!!). It should be relatively easy for me to try some changes here and profile the new code and understand the impacts.

Thanks again for all the help here. I really appreciate it.

That’s true, and it’s present in pretty much any language. Obviously statically typed languages don’t have dynamic typing, but runtime-known types and methods do exist in various forms: virtual function calls via subclasses of a known parent class, boxed objects that implement the same method interface, pattern matching over sum types, etc. Statically typed languages would restrict the type of the other inputs and output to allow static typing of variables and expressions.

Obviously dynamically typed languages in turn don’t have static typing (type inference resembles it, but it is only an optimization, not semantics), so functions are much freer to indulge in runtime type checking. That makes some programs easier to write; Peter Norvig demonstrated that 16 of the Gang of Four’s 23 design patterns usually written in statically typed object-oriented languages were invisible or vasty simplified in Lisp and Dylan. On the other hand, it’s easier to run into unoptimizable code, such as the inferred abstract types here. To bring back optimizations, we have to artificially narrow types in a way that statically typed languages would’ve forced us to e.g. FunctionWrappers.jl. Frankly, those languages still do it better, and the experimental juliac is the first mainstream incentive for us to write more than abstract type parameters and function barriers between type-unstable and type-stable code.

Not a lot of details, but that sounds like a function barrier approach, check the Performance Tips page of the Manual.

In the current framing, it sounds like you’ll have a simulation of a mission with (say) a million time-points. Some of them closely spaced (maybe miliseconds, and using say the moon’s co-ordinate system) and others coarsely spaced (seconds, and position relative to the sun), and it sounds like you want the flexibility that every time-point could be different – hence mutable structs with abstract fields, etc.

But really, the mission is only (say) 10 segments, each is a run of thousands of points of identical type. The code which must be fast is that dealing with successive time-points within a segment. So I suggest you try hard to make this innermost code completely concrete (no Unions, no abstract fields, everything type-stable). If there must be a struct Spacecraft per time-point, then it should be completely immutable, so that a Vector of them has efficient inline storage.

Then, further out, a whole mission is a collection of segments. This is where you allow for multiple time formats etc. But dealing with (say) 10 things, you can write sloppy convenient code.

I’ll investigate the FunctionWrappers approach too. One hybrid approach is to use the data and meta-data solution, and put all of that on the Time struct for example. In fact, that is what my code currently does for Time. I have a hodgepodge of interfaces for state, time, and other properties right now and I am trying to standardize on one clean approach.

I modelled the currently implemented Time design (not what was shown above, what is above is a refactoring proposal) from AstroPy.Time.

That design looks like this:

mutable struct Time
    jd_day::Real
    jd_fracday::Real
    scale::Symbol
    format::Symbol
end

Then

mutable struct Spacecraft
    time::Time
end

Even here I have type issues because I would MUCH rather use types for scale and format than symbols. But, this at least puts all the complexity on rich sub-structures, and avoids type change problems during execution because changing from UTC format to TAI format is just a change of the actual symbol and not a change of type. Perhaps I should standardize on this interface across the composition (I just don’t like symbols here, it feels non-idiomatic).

In your case, I think that’s the best approach. A function barrier:

Instead of

x = unavoidableinstability(X)
for _ in 1:100000
   do x things
end

do

function dothings(x)
    for i in 1:100000
        do x thngs
    end
end

x = unavoiableinstability(X)
dothings(x)

The dothings(x) function will be compiled for a concrete type of the input x, so you get away with a single instability, the single dynamic dispatch of dothings(x) instead of dynamic dispatch every time you touch x inside the loop.

Excellent questions, that get to the heart of where performance hits may occur. I have to do some chores but will reply later in more detail!

I would just leave the struct immutable and update via Accessors.jl. As this does not actually mutate, but recreates the struct it can also change types:

julia> using Accessors

julia> struct Foo{T}; x::T end

julia> f = Foo(1)
Foo{Int64}(1)

julia> @set f.x = "one"
Foo{String}("one")

I will have a mission broken into segments like you suggested, where each segment will be discretized into many points (how that discretization would look might be very different depending upon application/method i.e. shooting methods, collocation, orbit determination). So say for earth-moon transfer, there might two segments, one in Earth coordinates, one in moon coordinates.

My current architecture already models the segments (using a Directed Acyclic Graph architecture like NASA’s Copernicus software), and can handle the discretization within each segment. I don’t have a Spacecraft per discretization point though. I use DifferentialEquations.jl to solve the equations of motion (using a flat vector of state information like CartesianState, mass, etc.), then I map that data back to the Spacecraft struct as the simulation progresses. So, if that mapping from solve()'s state vector to the Struct fields is not fast, the whole system will suffer. That would be the bottleneck. Here is the good news… my sim is already running using this architecture and it is very fast. The Spacecraft is mutable, and the fields are parametrized and so concrete. However, I am only solving problems in a single system, I am not switching between systems, which is the next use case in terms of complexity. I could have one spacecraft for the Earth centered segment, and another for the moon-centered segment, but this puts a burden on the user (and feels clunky to me) instead of on me the developer to find an elegant solution.

So, if I can find a good way to change the types, when needed, I should be good to go as I have been benchmarking my code from the beginning to make sure I’m not making any noobie performance mistakes and so far so good.

One thing to consider is sticking to one standard coordinate and time “type” for the whole simulation. If you need more precision, you can try using Double64 from GitHub - JuliaMath/DoubleFloats.jl: math with more good bits or even BigFloat to store the state of the system.

The big advantage of this is that it will make answering questions like “how far in time and space are A and B?” trivial. You can, of course, convert back and forth to whatever coordinates are most convenient when printing things for the user or when doing calculations.

Switching behavior based on a Symbol in a struct is a great pattern, and has pros and cons compared to storing abstract types. I recently watched https://www.youtube.com/watch?v=wo84LFzx5nI which goes over the history of the idea of a struct. I think the TLDW is that abstractions and structs are useful, but you want your abstractions to help you solve your hardest computational problems. It is easy to add the wrong abstractions and make the hard parts harder, so I think you have to try a lot of things and see what works best.

It’s hard for me to get to this during the work week, this project is not my day job right now. Today I did some benchmarking on my first design above and saw the dramatic performance hit @Benny , @sgaure and others pointed out.

I am going to go with a design that was basically one of the earliest responses above, use data and metadata but put that on the composed objects to avoid struct bloat at the highest level (and the composed structs should be immutable, more on that below). Some brief rational; I will have listeners tracking structs to publish data during simulation runs, so Accessors.jl won’t work as it builds a new reference and the listener won’t see the main struct anymore. Unions will cause performance problems as types scale, and I will be working in a conservative domain where buy-in will take time and so I want to keep dependencies to core libraries whenever possible.

I benchmarked this approach, and it seems to work well. But I think I ran into why immutable structs are recommended. I am not a computer scientist… it is a bit baffling to me that creating a new struct is faster than “+=” on a single field on a mutable, nested struct, but I see it is a LOT faster ( I know it is compiler optimization, but still black magic to me). The benchmark below illustrates three cases; One uses an immutable nested struct and recreates the struct when data changes, the other two use a mutable nested struct and updates the field on the nested struct using two different approaches. This is probably Julia 101, but sometimes you have to see it to believe it; performing arithmetic on a field “x” of a nested struct like this inner.outer.x = 5 is horribly slow.

Thanks again for all the help, unless I hear any other concerning thoughts, I’m gonna get back to coding and refactoring some things based on these updates.

using BenchmarkTools

# Immutable version
struct MyImmutable
    x::Float64
end

mutable struct ContainerImmutable
    obj::MyImmutable
end

# This test recreates the struct when adding an increment to a field
function loop_rebuild!(c::ContainerImmutable, N)
    val = 0.0
    for i in 1:N
        val += 1e-6
        c.obj = MyImmutable(val)
    end
end

# Mutable version
mutable struct MyMutable
    x::Float64
end

mutable struct ContainerMutable
    obj::MyMutable
end

# This test dereferences the nested struct and then performs addition
function loop_mutate_decomposed!(c::ContainerMutable, N)
    o = c.obj
    for i in 1:N
        o.x += 1e-6
    end
    c.obj = o
end

# This test peforms addition directly on the mutable nested struct field
function loop_mutate_composed!(c::ContainerMutable, N)
    for i in 1:N
        c.obj.x += 1e-6
    end
end

# Benchmarks
N = 10^6
c_imm = ContainerImmutable(MyImmutable(0.0))
c_mut1 = ContainerMutable(MyMutable(0.0))
c_mut2 = ContainerMutable(MyMutable(0.0))

println("Addition by immutable rebuild. Fast!")
@btime loop_rebuild!($c_imm, $N)

println("Dereference mutable nested struct then perform addition.  Fast!")
@btime loop_mutate_decomposed!($c_mut1, $N)

println("Peform addition on nested mutable struct field directly, super slow!!")
@btime loop_mutate_composed!($c_mut2, $N)

Disclaimer, I don’t really get compilers either, but something to take away from this is that structs, fields, mutability, etc aren’t real concepts at the low level of architectures and processors, it’s just how we think and communicate in a high-level programming language. A decent optimizing compiler can reuse stack memory and seriously take apart a struct to the point that it’s just tweaking bytes at times instead of handling a whole struct. In your latest example, the structs only had 1 field, so the compiler didn’t even have to do much to them; they’re basically handled as the component field.

However, that is not fully exposed to the high level in Julia as it is in some other languages where variables and instances have different meanings e.g. C, C++, Rust. In those languages, you can specify the variable (not type) as mutable, then tweak the bytes of the instance wherever it is. In Julia, that’s only exposed for fields of mutable types, which usually end up on the heap to safely implement the core characteristic of multiple variables referencing the mutable instance, and that extra access indirection explains your latest benchmark (though I wouldn’t call 1.157ms to 2.590ms on my machine fast vs super slow). Instantiating an immutable struct does actually have to call the constructor, no matter how much is optimized away, and it’s easy to make a constructor that forces runtime overhead, usually for input processing or validation. The part that concerns the optimization described in the first paragraph is the new call that puts together the struct, and it is possible to dodge the typical constructors to just call it (arguably unsafe, but you can do this with mutable types already), but that’s still not the same as the user having semantic control over directly tweaking fields.

hmmmm. I have several benchmarking configs I am using. One that uses Spacecraft, and Time, and another that gets rid of the domain-specific stuff and just has generic structs shown above. I ran them multiple times and was seeing several orders of magnitude difference in the different approaches. Since this post I did had to do a reboot and so I have a new VS code session running, and I see pretty much what you see, a factor of 2. Weird. I saw micro second vs millisecond differences earlier, (or so I thought, but now can’t duplicate it). Maybe I just read the units wrong after a few nights of little sleep LOL. Anyway, I consider this to be good outcome. A substantial performance penalty for accessing data in a manner that is natural in other languages would not be ideal.