[ANN] Revise 3.0: faster and better

I’m pleased to announce that Revise 3.0 is available for beta-testing on Julia 1.0, 1.5, and nightly. Switch to it by updating your packages. (EDIT: this was originally written when Revise 3 was in beta but it is now released, and the changes are so minor it is not worth a new announcement.)

Revise 3 is an extensive rework with many exciting new features. It also has one area where an important new configuration might need to be tweaked via user feedback. Since that setting won’t change once the official Revise 3.0 is out, by beta testing you have a chance to affect the next stage of of the Revise experience. Read on for the full details.

Faster startup

The biggest negative of Revise 2.x is that it initially slows you down: on my machine, the aggregate overhead of using Revise (startup + first subsequent package load) is about 3.5s. With Revise 3, on my machine that time is down to 0.9s; on nightly (what will become Julia 1.6), it may get as low as 0.7s depending on the fate of a still-to-be-decided pull request to Julia.

Most of these gains have been achieved by deferring compilation, not making compilation faster or reducing the need for compilation. Basically, Revise now does much less work when it starts up, and goes to pains to ensure that it doesn’t trigger very much compilation until its functionality is actually needed. Consequently, this is mostly a bookkeeping improvement: time that used to be spent waiting for Revise to start will now be spent as extra latency for your first actual revision. To me, that still seems like a big win, because now Revise costs you very little until the moment when it’s actually delivering value. As before, it should be fast after the first revision; it’s only Revise’s own internal compilation time that I’m discussing here.

As part of the latency-reduction, Revise no longer tracks its own code or that of its dependencies. Call Revise.add_revise_deps() after startup if you want it to track itself.

Better error handling and more Julian error messages

The error-handling system has been extensively reworked. While there are still some printing differences between errors with and without Revise (mostly from using @error rather than error(...)), the messages and stack traces should now be very similar. It should also be less intrusive: now, only the first error triggered in a file is printed, and Revise warns you of outstanding revisions only by coloring your prompt yellow until you’ve resolved the outstanding errors.

More robustness when making revisions

Revise 2, while quite capable, still found ways to make annoying mistakes. For example, it used a somewhat arbitrary order to enact its revisions, and sometimes it got the order wrong. In Revise 3, if PkgA depends on PkgB, then PkgB’s revisions will be processed before PkgA’s, ensuring that new definitions in PkgB will be available for changes in PkgA. Likewise, if MyPkg loads "file1.jl" before it loads "file2.jl", it will process "file1.jl"'s revisions before processing those of "file2.jl". There are still corner cases that can break this (e.g., when "file1.jl" does an include("file2.jl") partway through and then uses some newly-defined types in "file2.jl"), but the number of opportunities for breakage should be vastly reduced.

A similar improvement concerns the process moving methods from one file to another, something that is fairly common when you’re working on larger packages or ecosystems. With Revise 2, unless you remembered to complete the move (copying to the destination and deleting from the source) before triggering a revision, Revise would “helpfully” delete the entire method from your session when you got around to deleting it from its original location. Revise 3 fixes this by (transiently) supporting multiple locations for method definitions, and deleting methods only when their last instantiation is gone.

Selective evaluation

From a technical standpoint, the most impressive new feature of Revise 3 is support for selective evaluation. This is an area where the defaults could still change depending on user feedback, and one of the major motivations for this beta period is to find out what choices work best in practice before locking them in throughout the Revise 3.x release cycle.

The short version of this feature can be summed up as follows: here on discourse, it’s been pretty common to advise users of includet to split their files, with “code” going in a package and “data processing” going in a script that does not get tracked by Revise. In Revise 3, this essentially happens automatically: Revise will (subject to alteration by some user-configurable settings) refuse to alter data but will happily modify methods, even when the two are densely interwoven.

Here’s the longer version: when defining methods, Julia keeps track of “dependencies,” so that if foo calls bar and you redefine bar, then foo will automatically be recompiled the next time you call it. We do not have the same notion of dependencies for data: if I used x to compute y, should I re-compute y when x changes? In Revise 2, the answer was “re-evaluate every changed expression and don’t worry about any dependencies.” That works sometimes, but sometimes causes big problems: either you got changes you didn’t want (forcing you to recompute the setup for your problem from scratch again), and/or long computation times since Revise performs changes using the interpreter.

Currently in the beta, the defaults are to re-evaluate all changes in packages and only method definitions in includet scripts. In other words, in includet scripts Revise will re-evaluate methods but it will not touch your data. That’s true even if you explicitly change a line defining a variable, because Revise doesn’t know what else you might need to recompute in order to stay consistent; if you want to modify data, you need to take care of it yourself. Fortunately, with both major IDEs now supporting inline evaluation, this is easier than ever. (An alternative is to alter Revise’s settings, described below.)

Perhaps the best way to illustrate this is with a demo; create a file

a = rand()                     # "data": will not be revised
b = 2                          # "data": will not be revised even if you change it
f() = 1                        # "method": will be revised
for (T, varname, e) in ((Float32, :c, rand()), (Float64, :d, rand()))
    @eval begin
        $varname = rand()      # hiding data inside an @eval doesn't fool Revise!
        g(::$T) = $e           # and it can still revise methods
    end
end

and then do this in your session:

julia> @show a b c d
a = 0.14642990011901702
b = 2
c = 0.6799833688203913
d = 0.12299256361483968
0.12299256361483968

julia> f()
1

julia> g(1.0)
0.6651919620816014

julia> g(1.0)                         # the value is static
0.6651919620816014

julia> g(1.0f0)
0.14537943287044364

julia> g(1.0f0)
0.14537943287044364

Now, change the value assigned to b and all the methods by modifying the script to

a = rand()                     # "data": will not be revised
b = 3                          # "data": will not be revised even if you change it
f() = 2                        # "method": will be revised
for (T, varname, e) in ((Float32, :c, rand()), (Float64, :d, rand()))
    @eval begin
        $varname = rand()      # hiding data inside an @eval doesn't fool Revise!
        g(::$T) = $e+1           # and it can still revise methods
    end
end

and then display the same values:

julia> @show a b c d
a = 0.14642990011901702
b = 2
c = 0.6799833688203913
d = 0.12299256361483968
0.12299256361483968

which you can see are unchanged (even though some are interwoven with method definitions), but all the methods have been redefined:

julia> f()
2

julia> g(1.0)
1.4382250538965402

julia> g(1.0f0)
1.4809866331295454

Some of the cool magic here is more obvious if you notice that Revise did need to assign values to some variables, notably T and e as it iterated through the loop, but c and d were not involved in method definition and hence were not modified.

If you don’t like the defaults, you can set __revise_mode__ = :evalassign and Revise will also evaluate changed assignments; __revise_mode__ = :eval will evaluate everything. But keep in mind that some of these settings will trigger new rand() numbers being assigned in some of those assignments above.

Again, by default this new mode only applies to includet: currently packages default to __revise_mode__ = :eval. You can set the mode of packages on a per-module basis by setting __revise_mode__ within the module.

If everybody hates the new defaults we can change them before Revise 3.0 release, but I will be reluctant to make changes after the release. So this is a good time to take Revise 3 for a spin and tell me what you like and what you dislike.

Those are the major changes; let the testing begin!

86 Likes

@tim.holy, would it be possible to have a branch of Rebugger compatible with Revise v3 already? Or is it too early for that?

1 Like

Too early. In fact probably none of the debuggersonly Debugger.jl is already compatible with JuliaInterpreter 0.8, which is required for Revise 3. However, I expect that to come fairly shortly, with the exception of Rebugger which needs more help from the community for it to stay maintained.

Thanks for the heads up.

Oh - and this is amazing! I can hardly imagine Julia without Revise - well, I can, but I really don’t want to. :slight_smile:

3 Likes

Is it possible the configure or change __revise_mode__ for includet ? I tried setting
__revise_mode__ = :evalassigned within the file, yet it seems includet isn’t picking up assignment changes.

Anywhere in Main should be fine, in theory. If you’re changing it in the file after includeting the file, then remember Revise won’t track the assignment so you won’t actually make it!

Oh, it’s also :evalassign and not :evalassigned.

Quick summary:

__revise_mode__ = :evalassign
a = [1]         # this line gets modified for :eval or :evalassign
push!(a, 2)     # this line gets modified only for :eval
f() = 1         # this line gets modified for :eval, :evalassign, or :evalmeth

I love this

5 Likes

Should we have two different includet functions?

One for methods + data, and one only for methods?

I personally don’t use includet so I have no stake in this game.

1 Like

Should we have two different includet functions? One for methods + data, and one only for methods?

Currently you can achieve that this way:

module JustMethods

__revise_mode__ = :evalmeth

# code

end

module MethodsAndData

__revise_mode__ = :eval

# more stuff

end

Then includet this file.

Of course one of these two modules could be Main if you don’t want the module barrier.

I personally don’t use includet so I have no stake in this game.

I don’t really use it either, although I suspect I’ll use it more now especially for writing tests. A nice use of the new functionality:

  • create a blank test file, perhaps with a few “helper” methods you expect to be useful in writing good tests
  • includet the test file
  • now start adding @testsets to the file

(You can includet a test file that already has @testsets in it, but you need to make sure the whole file runs successfully before Revise will track it.)

Now, this is pretty much a disaster with Revise 2: every time you revise a testset, the testset runs, and doesn’t run when you change your helper methods. With Revise 3, the helper methods will update but you use Alt-Enter in vscode when you want to try a testset. I find it to be a much nicer workflow, because you control when you want to run the tests.

4 Likes

Incidentally, one of my typical workflows involves using Revise.entr to automatically re-run(*) a stub test file each time a change in the package code is picked up by Revise.

This way, I can start working simultaneously on (1) a function implementing a feature in a package and (2) a typical intended use of that function. Each time I make incremental changes to the function, I get to immediately see how the output changes (without having to switch to the tests file and hitting Alt+Ret to run the relevant line). When I’m satisfied, I can easily turn that simple use case into a unit test.



(*) such stub test files typically contain only “data processing” statements, and with Revise2 I would simply re-include them.

4 Likes

FYI: I had to remove @essenciary’s Genie.jl package, and Atom. Juno seemed ok with that, just downloaded stuff, and started seemingly ok without Atom.jl. I’m however trying out VSCode anyway, and it’s the future, so I’m ok.

I’m not sure it’s right for Genie (or the ton of other packages having Revise as its dependency). Or should it allow, right now the beta? I thought Revise was just for interactive/your startup.jl file.

For Genie, Revise direct dependency, while juliahub.com doesn’t show me how Atom (or packages below) is related to Revise (juliahub needs to update regularly?). Is it indirectly tied to Julia itself? [seems just mentioned there, and only tested with.]:

(@v1.6) pkg> add Revise#revise3
   Updating git-repo `https://github.com/timholy/Revise.jl.git`
  Resolving package versions...
ERROR: Unsatisfiable requirements detected for package JuliaInterpreter [aa1ae85d]:
 JuliaInterpreter [aa1ae85d] log:
 ├─possible versions are: 0.1.1-0.8.0 or uninstalled
 ├─restricted to versions 0.8 by Revise [295af30f], leaving only versions 0.8.0
 │ └─Revise [295af30f] log:
 │   ├─possible versions are: 3.0.0 or uninstalled
 │   └─Revise [295af30f] is fixed to version 3.0.0
 └─restricted by compatibility requirements with Atom [c52e3926] to versions: 0.7.10-0.7.26 — no versions left
   └─Atom [c52e3926] log:
     ├─possible versions are: 0.8.0-0.12.21 or uninstalled
     ├─restricted to versions * by an explicit requirement, leaving only versions 0.8.0-0.12.21
     └─restricted by compatibility requirements with CodeTracking [da1fd8a2] to versions: 0.12.15-0.12.21 or uninstalled, leaving only versions: 0.12.15-0.12.21
       └─CodeTracking [da1fd8a2] log:
         ├─possible versions are: 0.1.0-1.0.2 or uninstalled
         └─restricted to versions 1 by Revise [295af30f], leaving only versions 1.0.0-1.0.2
           └─Revise [295af30f] log: see above

Yes, Genie uses Revise to make user code (within Genie apps) revisable so that editing the app “just works”. I think I can refactor it out and only keep Revise as a dependency of the Genie app (not of Genie itself). I’ll take a look.

1 Like

Revise 3 is released. Thanks to the testers for the feedback!

Compared to the beta there was a bug or two fixed, and the performance of handling @require blocks was greatly improved. If you’re loading more than just a small handful of packages, Revise’s overhead should be pretty negligible.

14 Likes