Skip to content

Commit 2eb1e87

Browse files
authored
Merge pull request #376 from JuliaDynamics/hw/bettercopy
add copy(::Network) and improve metadata dealiasing
2 parents bceddf1 + 9a8043e commit 2eb1e87

5 files changed

Lines changed: 146 additions & 8 deletions

File tree

NEWS.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44

55
- **ModelingToolkit v11 compatibility** ([#344](https://github.com/JuliaDynamics/NetworkDynamics.jl/pull/344)):
66
- Raises compat from `ModelingToolkit` v10 to v11. MTK v11 split into `ModelingToolkitBase` (MIT-licensed) and `ModelingToolkit` (AGPL-licensed); NetworkDynamics.jl now only requires the MIT-licensed `ModelingToolkitBase` as a direct dependency, dropping the AGPL dependency.
7-
- A custom structural simplification pipeline is added as the new default (`mtkcompile=false`). With `mtkcompile=true`, MTK's own `mtkcompile` is called: full structural simplification when `ModelingToolkit` is loaded, or the stub from `ModelingToolkitBase` (no structural simplification) when it is not.
7+
- A custom structural-simplification pipeline replaces MTK's `mtkcompile` as the new default (`mtkcompile=false`). It performs alias/linear-state elimination, breaks algebraic and nonlinear loops, and handles simple DAE index reduction. Set the global default via `NetworkDynamics.set_mtkcompile!`; use `mtkcompile=:compare` to run both backends and print a comparison for debugging. With `mtkcompile=true`, MTK's own `mtkcompile` is called: full structural simplification when `ModelingToolkit` is loaded, or the stub from `ModelingToolkitBase` (no structural simplification) when it is not.
8+
- MTK models now warn when they contain discrete variables (unsupported).
89
- New extension dependencies: `Moshi`, `Hungarian`.
910
- **Dependency version bumps**: `SymbolicUtils` ≥ 4, `Symbolics` ≥ 7.
1011
- `implicit_output` macro updated to use `Symbolics.@register_symbolic` instead of `ModelingToolkit.@register_symbolic`.
@@ -14,8 +15,10 @@
1415
- `find_fixpoint(nw, x0::NWState, p::NWParameter)` is deprecated; use `find_fixpoint(nw, x0::NWState)`.
1516
- The default initial state for fixpoint search now applies guesses and formulas automatically (`guess=true, apply_formulas=true`).
1617
- **`NWState` constructor** gains `guess`, `apply_formulas`, and `verbose` keyword arguments. With `default=true` (the default), values are filled in order: defaults/inits → `InitFormula`s → guesses (if `guess=true`) → `GuessFormula`s. `apply_formulas=true` by default; `guess=false` by default.
18+
- `initialize_component` / `initialize_componentwise` gain a `warn` keyword (default `true`) to silence initialization warnings.
1719
- `doctor` check: added smoketest for the observable function (`obsf`) of each component.
18-
- `SciMLBase@3` and `OrdinaryDfifEq@7` compat. Most notably, upstream `DiffEq` changed its default `initializealg` to *check* rather than *reinit* inconsistent initial conditions. To preserve the previous behavior, the `ODEProblem(nw::Network, ...)` constructor now defaults to `initializealg=BrownFullBasicInit()` (deliberately differing from the OrdinaryDiffEq default). Pass `initializealg=...` to the constructor to override. Also, the interface of the `VectorContinuousComponentCallback` changed to fit that of `VectorContinuousCallback`: the affect now receives a buffer with per-element up or down crossing information rather than a single event idx.
20+
- `SciMLBase@3` and `OrdinaryDiffEq@7` compat. Most notably, upstream `DiffEq` changed its default `initializealg` to *check* rather than *reinit* inconsistent initial conditions. To preserve the previous behavior, the `ODEProblem(nw::Network, ...)` constructor now defaults to `initializealg=BrownFullBasicInit()` (deliberately differing from the OrdinaryDiffEq default). Pass `initializealg=...` to the constructor to override. Also, the interface of the `VectorContinuousComponentCallback` changed to fit that of `VectorContinuousCallback`: the affect now receives a buffer with per-element up or down crossing information rather than a single event idx.
21+
- new `copy(::Network)` method (dealiases all components and buffers)
1922

2023
## v0.10.17 Changelog
2124
- **Open-loop linearization** ([#341](https://github.com/JuliaDynamics/NetworkDynamics.jl/pull/341)):

ext/NetworkDynamicsMTKExt.jl

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ include("MTKExt_simplification.jl")
3030
import NetworkDynamics: implicit_output, RHSDifferentialsError
3131
Symbolics.@register_symbolic implicit_output(x)
3232

33+
# `System`s and `Equation`s are large symbolic compile artifacts that are treated as
34+
# immutable once stored in component metadata (e.g. `:odesystem`, `:equations`). Opt
35+
# them into sharing so copying/reconstructing a component does not deep-copy them (which
36+
# is orders of magnitude slower). See `NetworkDynamics.dealias_metadata`.
37+
NetworkDynamics.dealias_metadata(x::Union{System,Equation}) = x
38+
3339
const MTKCOMPILE_DEFAULT = Ref{Union{Symbol,Bool}}(false)
3440
function NetworkDynamics.set_mtkcompile!(value)
3541
MTKCOMPILE_DEFAULT[] = value

src/component_functions.jl

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,19 @@ Base.@nospecializeinfer function _construct_comp(::Type{T}, @nospecialize(kwargs
613613
return c
614614
end
615615

616+
"""
617+
dealias_metadata(x)
618+
619+
Recursively copy `x` so the result can be mutated without affecting the original,
620+
while avoiding costly deep copies of large immutable objects. Used to dealias a
621+
component's `metadata` and `symmetadata` when it is copied or reconstructed (see
622+
`copy(::ComponentModel)` and `_reconstruct_comp`).
623+
"""
624+
dealias_metadata(@nospecialize(x)) = deepcopy(x)
625+
dealias_metadata(x::ComponentModel) = copy(x)
626+
dealias_metadata(x::AbstractDict{Symbol}) = typeof(x)(k => dealias_metadata(v) for (k, v) in x)
627+
dealias_metadata(x::Union{AbstractVector,Tuple,NamedTuple}) = map(dealias_metadata, x)
628+
616629
Base.@nospecializeinfer function _reconstruct_comp(::Type{T}, cf::ComponentModel, @nospecialize(kwargs)) where {T}
617630
fields = fieldnames(T)
618631
# fields to copy
@@ -623,9 +636,8 @@ Base.@nospecializeinfer function _reconstruct_comp(::Type{T}, cf::ComponentModel
623636
for f in nfields
624637
dict[f] = getproperty(cf, f)
625638
end
626-
for f in cfields
627-
dict[f] = deepcopy(getproperty(cf, f))
628-
end
639+
dict[:metadata] = dealias_metadata(getproperty(cf, :metadata))
640+
dict[:symmetadata] = dealias_metadata(getproperty(cf, :symmetadata))
629641

630642
# if there is no change in symbols and it had a clash before, keep the setting
631643
if !haskey(kwargs, :allow_output_sym_clash) && !haskey(kwargs, :sym) && !haskey(kwargs, :outsym)
@@ -1114,8 +1126,10 @@ end
11141126
"""
11151127
copy(c::NetworkDynamics.ComponentModel)
11161128
1117-
Shallow copy of the component model. Creates a deepcopy of `metadata` and `symmetadata`
1118-
but references the same objects everywhere else.
1129+
Shallow copy of the component model. `metadata` and `symmetadata` are dealiased via
1130+
`dealias_metadata` (fresh container spine, values deep-copied by default,
1131+
immutable artifacts like MTK `System`s shared) but all other fields reference the same
1132+
objects.
11191133
"""
11201134
@generated function Base.copy(c::ComponentModel)
11211135
fields = fieldnames(c)
@@ -1125,7 +1139,8 @@ but references the same objects everywhere else.
11251139
nfields = setdiff(fields, cfields)
11261140
assign = Expr(:block,
11271141
(:($(field) = c.$field) for field in nfields)...,
1128-
(:($(field) = deepcopy(c.$field)) for field in cfields)...)
1142+
:(metadata = dealias_metadata(c.metadata)),
1143+
:(symmetadata = dealias_metadata(c.symmetadata)))
11291144
construct = Expr(:call, c, [:($field) for field in fields]...)
11301145

11311146
quote

src/construction.jl

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,3 +523,14 @@ function Network(nw::Network;
523523

524524
return new_nw
525525
end
526+
527+
"""
528+
copy(nw::Network)
529+
530+
Copy a `Network`. The vertex and edge component models are copied (see
531+
[`copy(::ComponentModel)`](@ref) for how their metadata is dealiased) while the
532+
underlying graph is shared. Much cheaper than `deepcopy(nw)`, which would additionally
533+
clone the (immutable) graph, caches and buffers as well as the components' symbolic
534+
compile artifacts (e.g. the MTK `System`s stored in metadata).
535+
"""
536+
Base.copy(nw::Network) = Network(nw)

test/construction_test.jl

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,109 @@ end
348348
@test v1 != v2
349349
end
350350

351+
# Two metadata-value stand-ins: `DealiasImmutable` mimics an arbitrary third-party
352+
# immutable-wrapper-of-mutable-state (no dealias override -> must be defensively
353+
# deep-copied). `DealiasShared` mimics an MTK `System`/`Equation`: an immutable compile
354+
# artifact that opts into sharing via a `dealias_metadata` method, exactly as the MTK
355+
# extension does. See NetworkDynamics.dealias_metadata.
356+
struct DealiasImmutable
357+
payload::Vector{Int}
358+
end
359+
struct DealiasShared
360+
payload::Vector{Int}
361+
end
362+
NetworkDynamics.dealias_metadata(x::DealiasShared) = x
363+
364+
@testset "component metadata dealiasing" begin
365+
# copy(::ComponentModel)/reconstruction dealiases metadata & symmetadata via
366+
# `dealias_metadata`: rebuild the container spine, deepcopy leaves by default
367+
# (defensive), copy nested ComponentModels, and share only types that opt in.
368+
dealias = NetworkDynamics.dealias_metadata
369+
370+
# defensive default: an unknown immutable-wrapper is deep-copied, NOT shared
371+
imm = DealiasImmutable([1,2,3])
372+
@test dealias(imm) !== imm
373+
@test dealias(imm).payload !== imm.payload # inner mutable state independent
374+
# opt-in sharing (the System/Equation pattern): a type with a method is shared as-is
375+
shared = DealiasShared([1,2,3])
376+
@test dealias(shared) === shared
377+
378+
# mutable leaf -> deepcopy; ComponentModel -> copy (independent but equal)
379+
let v = [1,2,3]
380+
@test dealias(v) !== v && dealias(v) == v
381+
end
382+
inner = VertexModel(; g=(o,i,p,t)->nothing, outdim=1, name=:inner, check=false)
383+
@test dealias(inner) !== inner && dealias(inner) == inner
384+
385+
# Symbol-keyed containers: fresh spine of the same type, elements dealiased
386+
d = Dict{Symbol,Any}(:a => shared, :b => [1,2,3])
387+
d2 = dealias(d)
388+
@test d2 isa Dict{Symbol,Any} && d2 !== d
389+
@test d2[:a] === d[:a] # opted-in element shared
390+
@test d2[:b] !== d[:b] # mutable element deep-copied
391+
nt = (x = shared, y = [1,2,3])
392+
nt2 = dealias(nt)
393+
@test nt2 isa NamedTuple{(:x,:y)} && nt2.x === nt.x && nt2.y !== nt.y
394+
@test dealias([shared, shared])[1] === shared # vector: fresh, shared elements
395+
396+
# the dict rule is restricted to AbstractDict{Symbol}: a non-Symbol dict is deep-
397+
# copied wholesale (defensive), so even an opted-in element inside is NOT shared
398+
@test dealias(Dict{String,Any}("a" => shared))["a"] !== shared
399+
400+
# through copy(::ComponentModel)
401+
f = (dv, v, ein, p, t) -> nothing
402+
g = (out, in, p, t) -> nothing
403+
md = Dict{Symbol,Any}(:artifact=>shared, :user_data=>[10,20,30], :nested=>inner)
404+
v = VertexModel(; f, g, sym=[:x=>(;default=1)], outdim=2, metadata=md,
405+
name=:vp, check=false)
406+
v2 = copy(v)
407+
m, m2 = NetworkDynamics.metadata(v), NetworkDynamics.metadata(v2)
408+
409+
@test m2 !== m # fresh dict
410+
@test m2[:artifact] === m[:artifact] # opted-in artifact shared (no deepcopy!)
411+
@test m2[:user_data] !== m[:user_data] # mutable user data deep-copied, independent
412+
m2[:user_data][1] = -1
413+
@test m[:user_data][1] == 10 # original untouched
414+
@test m2[:nested] !== m[:nested] # nested ComponentModel: copied, not aliased
415+
@test m2[:nested] == m[:nested]
416+
417+
# symmetadata: fresh inner dicts, mutation of the copy is isolated
418+
sm, sm2 = NetworkDynamics.symmetadata(v), NetworkDynamics.symmetadata(v2)
419+
@test sm2 !== sm
420+
@test sm2[:x] !== sm[:x]
421+
set_default!(v2, :x, 99)
422+
@test get_default(v, :x) == 1
423+
@test get_default(v2, :x) == 99
424+
end
425+
426+
@testset "copy(::Network)" begin
427+
using NetworkDynamics: getcomp
428+
vertexf = (dv, u, esum, p, t) -> (dv[1] = esum[1]; nothing)
429+
edgeg = (out, src, dst, p, t) -> (out[1] = src[1] - dst[1]; nothing)
430+
v1 = VertexModel(; f=vertexf, g=[1], sym=[:x=>(;default=1.0)], insym=[:i], vidx=1, name=:v1, check=false)
431+
v2 = VertexModel(; f=vertexf, g=[1], sym=[:x=>(;default=2.0)], insym=[:i], vidx=2, name=:v2, check=false)
432+
v3 = VertexModel(; f=vertexf, g=[1], sym=[:x=>(;default=3.0)], insym=[:i], vidx=3, name=:v3, check=false)
433+
e1 = EdgeModel(; g=AntiSymmetric(edgeg), outsym=[:f], src=1, dst=2, check=false)
434+
e2 = EdgeModel(; g=AntiSymmetric(edgeg), outsym=[:f], src=1, dst=3, check=false)
435+
e3 = EdgeModel(; g=AntiSymmetric(edgeg), outsym=[:f], src=2, dst=3, check=false)
436+
nw = Network([v1,v2,v3], [e1,e2,e3])
437+
438+
nw2 = copy(nw)
439+
@test nw2 isa Network
440+
@test nv(nw2) == nv(nw)
441+
@test ne(nw2) == ne(nw)
442+
# components are copied (independent objects) but structurally equal
443+
@test getcomp(nw2, VIndex(1)) !== getcomp(nw, VIndex(1))
444+
@test getcomp(nw2, VIndex(1)) == getcomp(nw, VIndex(1))
445+
@test getcomp(nw2, EIndex(1)) !== getcomp(nw, EIndex(1))
446+
# graph is shared (immutable), equivalent to but cheaper than deepcopy
447+
@test nw2.im.g === nw.im.g
448+
# mutating a component of the copy must not leak into the original
449+
set_default!(nw2, VIndex(1, :x), 42.0)
450+
@test get_default(getcomp(nw2, VIndex(1)), :x) == 42.0
451+
@test get_default(getcomp(nw, VIndex(1)), :x) == 1
452+
end
453+
351454
@testset "test network-remake constructor" begin
352455
g = (out, in, p, t) -> nothing
353456
ge = (out, src, dst, p, t) -> nothing

0 commit comments

Comments
 (0)