Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

redefining struct #18

Open
tpapp opened this issue Jul 8, 2017 · 26 comments
Open

redefining struct #18

tpapp opened this issue Jul 8, 2017 · 26 comments
Labels
julialimit Feature requires changes to julia

Comments

@tpapp
Copy link
Contributor

tpapp commented Jul 8, 2017

Since structs cannot be redefined, attempting to do so currently gives a failure to evaluate changes warning.

I wonder if this can be worked around by triggering a reload of the entire module in this case.

@timholy
Copy link
Owner

timholy commented Jul 8, 2017

The problem is that then you have two notions of MyType, MyModuleNew.MyType and MyModuleOld.MyType. You don't see the Old/New when displayed (they both print as MyModule) but in fact they both exist. Any objects::MyModuleOld.MyType that you've created are still around, and you can't use them in methods that dispatch on MyModuleNew.MyType. But since they don't print differently the error messages are extremely confusing, e.g.,

ERROR: MethodError: no method matching foo(::MyModule.MyType)
Closest candidates are:
  foo(::MyModule.MyType) at REPL[2]:1

(The first is really MyModuleOld.MyType and the second is MyModuleNew.MyType.) I don't relish trying to explain that to anyone.

@timholy
Copy link
Owner

timholy commented Jul 8, 2017

Here's an actual example:

julia> module MyModule
       struct MyType end
       foo(x::MyType) = 1
       end
MyModule

julia> x = MyModule.MyType()
MyModule.MyType()

julia> MyModule.foo(x)
1

julia> module MyModule
       struct MyType end
       foo(x::MyType) = 1
       end
WARNING: replacing module MyModule
MyModule

julia> MyModule.foo(x)
ERROR: MethodError: no method matching foo(::MyModule.MyType)
Closest candidates are:
  foo(::MyModule.MyType) at REPL[4]:3

@tpapp
Copy link
Contributor Author

tpapp commented Jul 8, 2017

I am aware of the issue, but still consider reloading the whole module the best solution. It is recommended in the manual, and is still preferable to restarting Julia, which is the only other workaround I am aware of, and is usually much more time-consuming (especially if one uses Plots.jl etc) than regenerating some values.

I understand your reluctance to deal with this in Revise.jl, as it would be confusing. Perhaps a warning issued in the REPL, or an option that tells Revise.jl to attempt this, would work. But I agree that these are kludges.

@timholy
Copy link
Owner

timholy commented Jul 8, 2017

I think I wrote that documentation, but turnabout is fair play 😄.

As you probably know, it gets complicated really fast. If you reload A, but have module B that depends on A, you also need to reload B. And (as shown in that manpage) all your variables need to be redefined. Perhaps we could store the entire REPL history for this session and re-execute it? What about statements that, e.g., chop the last 10 bytes off a file, did you really mean to do that twice?

The problems seem quite daunting to me. The "reload all dependent modules" problem may be fairly easily solvable in cases of precompilation because the cache file holds a reliable record of dependencies. (For non-precompiled modules we'd have to rely on a parsing strategy, which might work most of the time but would be fundamentally less reliable.)

The really scary problem is what to do with old variables. I'm not against the idea of trying to do something, but I don't have any good ideas for how to solve it.

@timholy
Copy link
Owner

timholy commented Jul 9, 2017

OK, I confess you got me thinking: JuliaLang/julia#22721

@vtjnash
Copy link
Collaborator

vtjnash commented Jul 15, 2017

I was wondering: instead of renaming the old type, would it be possible to rename the new one instead? It won't get every case, but perhaps just manually calling eval(rename!(expand())) on everything Revise.jl-aware would cover enough usages to be useful?

@timholy
Copy link
Owner

timholy commented Jul 16, 2017

That might be work. We'd still have to use methodswith and ask for the callers of the old-type constructor. If the backedges are going away, is that something we can do?

@vtjnash
Copy link
Collaborator

vtjnash commented Jul 16, 2017

It could, but you might also just be able to just look through your parse tree for that symbol. The parse tree will perhaps also be more accurate at finding the actual reference to the type, which doesn't necessarily need to be the same function as the inference tree leading up to an allocation of that type.

@timholy
Copy link
Owner

timholy commented Jul 16, 2017

Yuck.

julia> struct Foo end

julia> function fakefoo()
           Foo = sqrt
           return Foo(7)
       end
fakefoo (generic function with 1 method)

julia> fakefoo()
2.6457513110645907

Another thing I haven't mentioned yet is that I was planning on trying to scan the eval_user_input args for old types by searching for #RV#, and then warn("I see you have some old types there. It'd be a shame to not use the shiny new ones."). But I could keep a list of specific types to watch out for.

@c42f
Copy link
Collaborator

c42f commented Mar 12, 2019

@cstjean just came up with a surprisingly simple but clever idea which could be an effective workaround: https://discourse.julialang.org/t/advice-for-dealing-with-struct-during-development/21732/6

The only obvious downsides being that you'd have to annotate the structs which you anticipate changing before loading the module, and that using such structs would be slow. That seems acceptable to me though.

@timholy, would you consider this (or something similar) for inclusion in Revise? It seems like a developer tool which would be somewhat specific to a Revise-based workflow.

@mauro3
Copy link
Contributor

mauro3 commented Mar 12, 2019

One thing which that trick doesn't allow is type parameters, or at least no changing type parameters.

@timholy
Copy link
Owner

timholy commented Mar 13, 2019

I'm interested in exploring this. No bandwidth now, though, not until #243 merges and the associated debugger stack is out there. (And also not a great time to make lots of changes in the Revise codebase, I recommend waiting a week or so.)

@cstjean
Copy link
Collaborator

cstjean commented Mar 13, 2019

It seems like a developer tool which would be somewhat specific to a Revise-based workflow.

It could be useful for those who include files, too. My inclination would be to make it a separate package, unless there was a specific reason to hook it into Revise.jl. That said, I don't have time to work on it either, so that decision is not up to me.

@adam-r-kowalski
Copy link

Could this be resolved by having a system similar to Figwheel. I was an avid user of ClojureScript and this made the development lifecycle amazing. They handled situations like this with their guide for writing reloadable code.

A quick summary is that they had def and defonce (it's a lisp like language). The idea is that you reload everything which is not defonce. This means that if you wish to preserve some state (large dataframes, flux models, etc...) you can tag them with defonce and those will not get reloaded. This way structs can be reloaded and you can safely destroy anything which is not tagged to persist through the reload.

Additionally hooks were provided before-load and after-load to give users a chance to maybe serialize some state down to a file before reloading everything, and then load it back up after.

@timholy
Copy link
Owner

timholy commented May 5, 2019

defonce won't solve the issue here; the problem is not that it tries to redefine a type, it's that it fails to redefine the type, and of course if you never try, you'll fail. Very easy to illustrate:

julia> struct MyStruct
           x::Int
       end

julia> struct MyStruct
           x::Int16
       end
ERROR: invalid redefinition of constant MyStruct
Stacktrace:
 [1] top-level scope at none:0

julia> struct MyStruct
           x::Int
       end

So Julia only complains if you're making changes. Unfortunately if you've made that change in the source code, you'd like it to take effect.

That said, in theory there are places where annotating a block as "load once" might be necessary. An example might be code that initializes a C library, and for which you'd get a crash if you tried to initialize it a second time. I've kind of been amazed that it hasn't really come up yet---either it just doesn't come up, or people aren't reporting problems. I suspect that Julia's precompilation imposes a discipline that makes the large majority of Julia packages well-suited for code-redefinition.

@c42f
Copy link
Collaborator

c42f commented Jun 15, 2019

@BeastyBlacksmith mentioned the following on discourse: https://github.com/BeastyBlacksmith/ProtoStructs.jl

@timholy timholy added the julialimit Feature requires changes to julia label Nov 22, 2019
@rapus95
Copy link
Contributor

rapus95 commented Jan 7, 2020

I guess you already considered this, but I'm curious, why you didn't like those somewhat hacky repl tricks:
allow a prefix to be applied to all calls of a given module. Say I want to develop module X, then I'd call set_active_module(X) which would rewrite dosomething() to X.dosomething(), thus, doing the implicit "Main." prefixing, that is said to happen when parsing, automagically redirecting. Alternatively rebind every variable of X into a Main-global variable. That way exchanging bindings on reload would still work. Sure, there's some performance penalty, but since this is an opt-in solution (via the set_active_module(X) function) it could be a welcomed convenience increase.

@timholy
Copy link
Owner

timholy commented Jan 7, 2020

I'm trying to get away from "somewhat hacky": the main goal in Revise development over the last year or so has been to make it Just Work for almost everybody. I'm fairly pleased with where it is now, and hopefully the slow rate of bug reports recently means that others are too. (If not, please report bugs!) I put problems into different categories, and "should work but doesn't" (aka, a "real bug") is far more serious than "a feature that might be nice but isn't properly supported by Julia itself." By https://github.com/timholy/Revise.jl/issues just about the only "real bug" that I know about is #249. Hopefully most of the infrastructure needed to fix it is already in place, though it's pretty complicated.

Still, it may be getting to the point where it's worth considering tackling this problem again. I had a brief run at JuliaLang/julia#22721 recently, but I'm back to thinking that's hard. Renaming in the other direction might be the the better approach. I'd certainly welcome PRs, though I'm unlikely to accept a solution that only works sometimes.

@vtjnash
Copy link
Collaborator

vtjnash commented Jan 8, 2020

In the context of addressing constant redefinition ala JuliaLang/julia#22721 like JuliaLang/julia#265 (ie with world versioning), we were recently talking about this in the Julialab. It turned out we already have all pieces for it by doing via a small transform: by making a new module that has all the same bindings (but the one) as the old module, we can redefine the type in the new module and then it's defined mechanically if we're referring to the old or new module of that name. But otherwise they have the same bindings (and functions and methods), so using the code won't particularly notice the difference for accessing through the old or new! (Sorry I have no sample code to demo, but happy to answer questions)

@timholy
Copy link
Owner

timholy commented Jan 8, 2020

by making a new module that has all the same bindings

Same name or different? It's pretty easy to do "different," but then that leaves you with questions to answer about how you integrate the new name into other code.

and then it's defined mechanically if we're referring to the old or new module of that name

Can you elaborate?

@vtjnash
Copy link
Collaborator

vtjnash commented Jan 8, 2020

Same name—since it's a toplevel module, it's just a reference in Base.loaded_modules. Then there's lots of recursive ripple effect where it'll look through all modules with existing references to the old module or name and make a copy of them too. It could be a fair amount to copy, but the end result is a clone of the world with one binding changed (this is almost exactly how the JuliaLang/julia#265 fix works—it clones the "world" rather than modifying and invalidating it).

If you call @eval OldModule <> it'll see the old binding, if you call @eval NewModule <> the new code will see the new binding. The contents of each are the same though, so adding methods via NewModule will make them visible in OldModule too (and vice versa).

@timholy
Copy link
Owner

timholy commented Jan 8, 2020

Really interesting. If I understand correctly, everything that references that module needs to be recompiled, right? My biggest concern is that could be a lot of recompilation; suppose you change a type in Base, wouldn't that mean that basically everything in your loaded session needs to be recompiled (including Base itself)? Revise currently goes to strenuous effort to reduce the amount of recompilation.

I should say I had a pretty serious stab at this in my teh/typeredef branch and specifically

Revise.jl/src/Revise.jl

Lines 665 to 731 in bd5f1a0

function revise(queue=revision_queue)
sleep(0.01) # in case the file system isn't quite done writing out new files
pdfiles = OrderedDict{Tuple{PkgData,String},ModuleExprsSigs}()
# Step 1: file parsing
for (pkgdata, file) in queue
try
_, mexsnew = mexs_pair(pkgdata, file)
pdfiles[(pkgdata, file)] = mexsnew
catch err
# parsing errors
@error "Revise failed to parse $(abspath(file, pkgdata))" exception=(err, trim_toplevel!(catch_backtrace()))
push!(queue_errors, (pkgdata, file))
end
end
# Step 2: diff computation to determine methods & types to be "deleted"
methods, types = Set{Method}(), Base.IdSet{Any}()
for ((pkgdata, file), mexsnew) in pdfiles
methods_types_to_delete!(methods, types, pkgdata, file, mexsnew)
end
# Step 3: traverse all existing methods, looking for ones that are either
# restricted to one of `types` or specialized for one of `types`
if !isempty(types) && isdefined(Base, :rename_binding)
newmethods = Set{Method}()
action(m::Method) = push!(newmethods, m)
action(ci::Core.CodeInstance) = ci.max_world = 0
predicate(obj) = typesmatch(types, obj)
traverse(predicate, action) # FIXME: set ci.max_world on workers
# Ensure that we have the expression caches for all new methods
add_caches!(pdfiles, methods, newmethods)
# Clear the corresponding definitions
clear_cache_for_types(newmethods)
end
typenames = [Base.unwrap_unionall(T).name.name for T in types]
# Step 4: invalidate types
invalidate_types(types)
# Step 5: delete methods
delete_methods(methods)
# Step 6: re-evaluation
if !isempty(types) && isdefined(Base, :rename_binding)
# FIXME: this is necessary to get the tests to pass!
@info "Redefining the following types: $typenames"
end
for ((pkgdata, file), mexsnew) in pdfiles
try
define_new_methods_types!(pkgdata, file, mexsnew)
delete!(queue_errors, (pkgdata, file))
catch err
# Abort the remainder of the file if we error. This will reduce ridiculously
# long error dumps if, say, we throw an error upon type definition.
push!(queue_errors, (pkgdata, file))
push!(revision_errors, (pkgdata, file, err, catch_backtrace()))
@error "Revise failed to evaluate $(abspath(file, pkgdata))" exception=(err, trim_toplevel!(bt))
end
end
# Step 7: clean up
empty!(revision_queue)
if !isempty(queue_errors)
io = IOBuffer()
for (pkgdata, file) in queue_errors
println(io, " ", abspath(file, pkgdata))
end
str = String(take!(io))
@warn "Due to a previously reported error, the running code does not match saved version for the following files:\n$str"
end
tracking_Main_includes[] && queue_includes(Main)
return nothing
end
for the high-level overview of how it worked. Of course, since JuliaLang/julia#22721 isn't viable, this branch isn't workable, but you'll see it's quite surgical in what it chooses to invalidate.

@timholy
Copy link
Owner

timholy commented Sep 17, 2020

@vtjnash, would be interested in restarting this conversation at some point. With Revise 3 out I think most of the remaining brittleness has been eliminated, and it might be time to consider expanding into new territory.

@Amval
Copy link

Amval commented Nov 17, 2020

@timholy , hat would be great. For my current Julia use case, I have built a framework. Whenever I am just building client code (on top of the framework), Revise works flawlessly.

The problem is when I am doing work inside the framework. If I need to come close to any struct I am obliged to restart the environment, which is a productivity killer. I love Julia's language design, but unfortunately the current worflow doesn't suit me and my team very well, to the point that we are considering migration :(

I think if there was a workaround this, one of the major paint points of developing in Julia would be solved.

@JinraeKim
Copy link

Totally agree with @Amval.
I think that the main drawback of Julia in developing comes from manipulating "the framework" (or, I would say a package).
Using existing packages is very nice though.

@timholy I would like to know whether there is any progress or plan on this issue.

@cstjean
Copy link
Collaborator

cstjean commented Apr 9, 2021

JuliaLang/julia#40399 is the latest.

Keno added a commit to JuliaLang/julia that referenced this issue Jun 2, 2024
This implements world-age partitioning of bindings as proposed in #40399.
In effect, much like methods, the global view of bindings now depends on
your currently executing world. This means that `const` bindings can now
have different values in different worlds. In principle it also means
that regular global variables could have different values in different
worlds, but there is currently no case where the system does this.

# Motivation
The reasons for this change are manifold:

1. The primary motivation is to permit Revise to redefine structs.
   This has been a feature request since the very begining of Revise
   (timholy/Revise.jl#18) and there have been
   numerous attempts over the past 7 years to address this, as well as
   countless duplicate feature request. A past attempt to implement the
   necessary julia support in #22721 failed because the consequences and
   semantics of re-defining bindings were not sufficiently worked out.
   One way to think of this implementation (at least with respect to types)
   is that it provides a well-grounded implementation of #22721.

2. A secondary motivation is to make `const`-redefinition no longer UB
   (although `const` redefinition will still have a significant performance
   penalty, so it is not recommended). See e.g. the full discussion in #54099.

3. Not currently implemented, but this mechanism can be used to re-compile code
   where bindings are introduced after the first compile, which is a common
   performance trap for new users (#53958).

4. Not currently implemented, but this mechanism can be used to clarify the semantics
   of bindings import and resolution to address issues like #14055.

# Implementation

In this PR:
 - `Binding` gets `min_world`/`max_world` fields like `CodeInstance`
 - Various lookup functions walk this linked list using the current task world_age as a key
 - Inference accumulates world bounds as it would for methods
 - Upon binding replacement, we walk all methods in the system, invalidating those whose
   uninferred IR references the replaced GlobalRef
 - One primary complication is that our IR definition permits `const` globals in value position,
   but if binding replacement is permitted, the validity of this may change after the fact.
   To address this, there is a helper in `Core.Compiler` that gets invoked in the type inference
   world and will rewrite the method source to be legal in all worlds.
 - A new `@world` macro can be used to access bindings from old world ages. This is used in printing
   for old objects.
 - The `const`-override behavior was changed to only be permitted at toplevel. The warnings about
   it being UB was removed.

Of particular note, this PR does not include any mechanism for invalidating methods whose signatures
were created using an old Binding (or types whose fields were the result of a binding evaluation).
There was some discussion among the compiler team of whether such a mechanism should exist in base,
but the consensus was that it should not. In particular, although uncommon, a pattern like:
```
f() = Any
g(::f()) = 1
f() = Int
```
Does not redefine `g`. Thus to fully address the Revise issue, additional code will be required in
Revise to track the dependency of various signatures and struct definitions on bindings.

# Demo
```
julia> struct Foo
               a::Int
       end

julia> g() = Foo(1)
g (generic function with 1 method)

julia> g()
Foo(1)

julia> f(::Foo) = 1
f (generic function with 1 method)

julia> fold = Foo(1)
Foo(1)

julia> struct Foo
               a::Int
               b::Int
       end

julia> g()
ERROR: MethodError: no method matching Foo(::Int64)
The type `Foo` exists, but no method is defined for this combination of argument types when trying to construct it.

Closest candidates are:
  Foo(::Int64, ::Int64)
   @ Main REPL[6]:2
  Foo(::Any, ::Any)
   @ Main REPL[6]:2

Stacktrace:
 [1] g()
   @ Main ./REPL[2]:1
 [2] top-level scope
   @ REPL[7]:1

julia> f(::Foo) = 2
f (generic function with 2 methods)

julia> methods(f)
# 2 methods for generic function "f" from Main:
 [1] f(::Foo)
     @ REPL[8]:1
 [2] f(::@world(Foo, 0:26898))
     @ REPL[4]:1

julia> fold
@world(Foo, 0:26898)(1)
```

# Performance consideration
On my machine, the validation required upon binding replacement for the full system image takes about 200ms.
With CedarSim loaded (I tried OmniPackage, but it's not working on master), this increases about 5x. That's
a fair bit of compute, but not the end of the world. Still, Revise may have to batch its validation. There
may also be opportunities for performance improvement by operating on the compressed representation directly.

# Semantic TODO

- [ ] Do we want to change the resolution time of bindings to (semantically) resolve them immediately?
- [ ] Do we want to introduce guard bindings when inference assumes the absence of a binding?

# Implementation TODO
- [ ] Precompile re-validation
- [ ] Various cleanups in the accessors
- [ ] Invert the order of the binding linked list to make the most recent one always the head of the list
- [ ] CodeInstances need forward edges for GlobalRefs not part of the uninferred code
- [ ] Generated function support
Keno added a commit to JuliaLang/julia that referenced this issue Jun 9, 2024
This implements world-age partitioning of bindings as proposed in #40399.
In effect, much like methods, the global view of bindings now depends on
your currently executing world. This means that `const` bindings can now
have different values in different worlds. In principle it also means
that regular global variables could have different values in different
worlds, but there is currently no case where the system does this.

The reasons for this change are manifold:

1. The primary motivation is to permit Revise to redefine structs.
   This has been a feature request since the very begining of Revise
   (timholy/Revise.jl#18) and there have been
   numerous attempts over the past 7 years to address this, as well as
   countless duplicate feature request. A past attempt to implement the
   necessary julia support in #22721 failed because the consequences and
   semantics of re-defining bindings were not sufficiently worked out.
   One way to think of this implementation (at least with respect to types)
   is that it provides a well-grounded implementation of #22721.

2. A secondary motivation is to make `const`-redefinition no longer UB
   (although `const` redefinition will still have a significant performance
   penalty, so it is not recommended). See e.g. the full discussion in #54099.

3. Not currently implemented, but this mechanism can be used to re-compile code
   where bindings are introduced after the first compile, which is a common
   performance trap for new users (#53958).

4. Not currently implemented, but this mechanism can be used to clarify the semantics
   of bindings import and resolution to address issues like #14055.

In this PR:
 - `Binding` gets `min_world`/`max_world` fields like `CodeInstance`
 - Various lookup functions walk this linked list using the current task world_age as a key
 - Inference accumulates world bounds as it would for methods
 - Upon binding replacement, we walk all methods in the system, invalidating those whose
   uninferred IR references the replaced GlobalRef
 - One primary complication is that our IR definition permits `const` globals in value position,
   but if binding replacement is permitted, the validity of this may change after the fact.
   To address this, there is a helper in `Core.Compiler` that gets invoked in the type inference
   world and will rewrite the method source to be legal in all worlds.
 - A new `@world` macro can be used to access bindings from old world ages. This is used in printing
   for old objects.
 - The `const`-override behavior was changed to only be permitted at toplevel. The warnings about
   it being UB was removed.

Of particular note, this PR does not include any mechanism for invalidating methods whose signatures
were created using an old Binding (or types whose fields were the result of a binding evaluation).
There was some discussion among the compiler team of whether such a mechanism should exist in base,
but the consensus was that it should not. In particular, although uncommon, a pattern like:
```
f() = Any
g(::f()) = 1
f() = Int
```
Does not redefine `g`. Thus to fully address the Revise issue, additional code will be required in
Revise to track the dependency of various signatures and struct definitions on bindings.

```
julia> struct Foo
               a::Int
       end

julia> g() = Foo(1)
g (generic function with 1 method)

julia> g()
Foo(1)

julia> f(::Foo) = 1
f (generic function with 1 method)

julia> fold = Foo(1)
Foo(1)

julia> struct Foo
               a::Int
               b::Int
       end

julia> g()
ERROR: MethodError: no method matching Foo(::Int64)
The type `Foo` exists, but no method is defined for this combination of argument types when trying to construct it.

Closest candidates are:
  Foo(::Int64, ::Int64)
   @ Main REPL[6]:2
  Foo(::Any, ::Any)
   @ Main REPL[6]:2

Stacktrace:
 [1] g()
   @ Main ./REPL[2]:1
 [2] top-level scope
   @ REPL[7]:1

julia> f(::Foo) = 2
f (generic function with 2 methods)

julia> methods(f)
 [1] f(::Foo)
     @ REPL[8]:1
 [2] f(::@world(Foo, 0:26898))
     @ REPL[4]:1

julia> fold
@world(Foo, 0:26898)(1)
```

On my machine, the validation required upon binding replacement for the full system image takes about 200ms.
With CedarSim loaded (I tried OmniPackage, but it's not working on master), this increases about 5x. That's
a fair bit of compute, but not the end of the world. Still, Revise may have to batch its validation. There
may also be opportunities for performance improvement by operating on the compressed representation directly.

- [ ] Do we want to change the resolution time of bindings to (semantically) resolve them immediately?
- [ ] Do we want to introduce guard bindings when inference assumes the absence of a binding?

- [ ] Precompile re-validation
- [ ] Various cleanups in the accessors
- [ ] Invert the order of the binding linked list to make the most recent one always the head of the list
- [ ] CodeInstances need forward edges for GlobalRefs not part of the uninferred code
- [ ] Generated function support
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
julialimit Feature requires changes to julia
Projects
None yet
Development

No branches or pull requests

10 participants