[ANN] ReactantNitro.jl: Reactant-first training framework

I’m excited to finally release ReactantNitro.jl; a Reactant-first training framework inspired by PyTorch Lightning, but specifically designed around making working with Reactant.jl easy while still allowing for a large amount of flexibility without sacrificing performance. While Reactant and Enzyme have some truly incredible potential to speed up machine learning workflows, there are a number of stumbling blocks researchers and ML practitioners often hit coming from other ML ecosystems.

We developed ReactantNitro.jl at Medical Metrics Inc. to explicitly avoid these sorts of problems while keeping the signifigant speedup we were seeing for our models compared to PyTorch eager mode and torch.compile. It’s been in the works for around half a year now and has been used internally to successfully train many different models.

Experiment DSL designed specifically for Reactant

One of the core features of the framework is an Experiment DSL which lets you categorize your models parameters in a way that the framework can ensure correct and optimal behavior:

Marker Reaches traced code as In the compile key? Changing the value
GraphConst{T} a baked literal yes recompiles, correctly: a different value is a different program
Device{T} a device-resident traced input no, by construction never recompiles: sweep it, schedule it, rewrite it live
unmarked, i.e. Host{T} not at all no never recompiles: driver-only, invisible to the tracer

The frameworks callback hooks are keyed on your @experiment struct:

@experiment struct MyExp
    "Structural: changes the emitted graph, so it bakes and is part of the compile key."
    width::GraphConst{Int} = 128

    "A traced input: sweep it or schedule it without recompiling."
    smoothing::Device{Float32} = 0.05f0

    "Unmarked, therefore Host: driver-only and invisible to the tracer."
    max_epochs::Int = 20
end

Structuring things this way eliminates entire categories of pitfalls:

  • The tracer will never trace over your dataset (Host by default); for large datasets, this can massively increase Reactant’s compile time
  • One of the first principles of the framework is reducing wasted compile time. Changes to GraphConst values are the only thing beyond code changes that need a recompile, which allows the framework to know when we should invalidate a cached program. Host values are guaranteed to be invisible to the compiled program, so they cannot change it. Changes to Device values do not need a recompile unless the shape or type of it changes.
  • The framework knows exactly when Device values should be transferred to the device. For scheduled values it happens every step, for constant buffers it’s done a single time at the start of training. More on flexible scheduling later.

Eliminate Boilerplate

We take inspiration from PyTorch Lightning when it comes to eliminating boilerplate by providing hooks that are dispatched on your experiment type. You handle your forwards pass, loss function, and metrics; the framework handles the boilerplate glue that connects it all while aiming to still provide the user with sufficient flexibility. A manual optimization mode is also provided which can be used to train models like GANs.

ReactantNitro.forward(::MnistMLP, model, ps, st; img) = Lux.apply(model, img, ps, st)

function ReactantNitro.loss(e::MnistMLP, logits; label)
    smoothed = (1f0 - e.smoothing) .* label .+ e.smoothing / 10f0
    return -sum(smoothed .* logsoftmax(logits; dims = 1)) / size(label, 2)
end

These hooks are used to compile three different programs: training forwards/backwards, training optimizer step, and validation/test/infer. The reason we don’t roll the optimizer step into the forwards/backwards is to provide flexibility to do things like gradient accumulation and allow you to switch optimisers without recompiling the entire program.

REPL Driven Workflow + Kaimon MCP Extension

Every part of the interface was designed to be used in a REPL making use of Revise.jl to allow you to quickly iterate and test new ideas without unnecessary recompile. The invalidation system is Revise aware and tracks changes that would invalidate compiled programs, so compile only happens when its actually needed.

ReactantNitro.jl also has a Kaimon.jl extension which lets your agents drive training jobs entirely through MCP and a full set of skills for your agents to use. Thanks to @kahliburke for all the amazing work he has done on making this possible in the Julia ecosystem! We have experimented some with integrating the framework into KaimonSlate.jl as well, some follow up work is likely needed on my end to make it entirely seamless.

Flexible Metrics

Metrics can either be handled on the device side for maximum performance or host side for increased flexibility and avoiding re-compile after changing the metrics. This can be defined separately between for train and validation/test. Reductions are structured in a normalization aware way for validation/test, you return a tuple each step with (scalar, n) and the n is used in ReactantNitro.finalize_metrics. You can also return (arr, nothing) at each step and handle the reduction yourself for metrics like AUROC or R^2 that need to be done over all of the individual samples.

Parameter Schedules

You can schedule pretty much any kind of Device (including optimizer parameters like learning rate, weight decay, etc)

@experiment struct Seq2SeqExp
    teacher_forcing::Device{Float32} = 1f0
    max_epochs::Int = 20
end

ReactantNitro.schedules(::Seq2SeqExp) = (;
    device = (; teacher_forcing = total -> t -> max(0f0, 1f0 - 2f0 * t / total)),
    opt    = (; eta = total -> OneCycle(total, 1f-3)),   # ParameterSchedulers.jl, no dependency
)

This even works with more complicated configurations such as multiple optimisers keyed by the individual optimizer name.

Export Models to ReactantServer.jl

A ReactantServerExport.jl extension is provided which makes it straightforward to export your nitro models to ReactantServer.jl for serving in production. In the future we are also looking at different export paths such as tensorflow/jax which would allow for deployment in existing serving stacks.

Status

I plan on registering the package shortly. The API should remain stable as I’m fairly confident it can handle most common use cases, but eventually there will be a 1.0.0 release where we can make improvements to the interface based on feedback.

Work is ongoing to provide end-to-end examples, but the framework is simple enough that getting something off of the ground shouldn’t be terribly difficult.

AI Disclaimer

  • This post was written entirely by me with no AI assistance.
  • Coding agents were used heavily during the development of ReactantNitro.jl, but the design is entirely mine down to the last detail. The framework has been used to produce many models that had their predictions independently verified during internal testing. Of course as with all software there may be bugs; if you encounter any issues we would love an issue report or a PR addressing them.
  • Documentation is largely AI generated but reviewed / refined by me; the process of improving it is ongoing.

Very cool!

Before registering, I decided to refine the experience of using ReactantNitro in a REPL with PrettyTables.jl integration (thanks @Ronis_BR!) and a compile aware ProgressMeter.jl so you don’t have to ask whether something hung or if it’s just compiling.

I also added an extension for TensorBoardLogger.jl and I’m planning to add one for both DearDiary.jl and a CometML logger which I have yet to register. PRs adding other loggers would be much appreciated if you use them!

Finally I wrote an MLUtils.jl extension to ensure that your DataLoader behaves exactly how you expect and warns / raises errors when using combinations of options that do no map into ReactantNitro cleanly. In general DataLoader is the “golden path” where where things should just work, and as such its the suggested way to setup your build_data function. You can technically return any iterable, but having a more complete contract lets the framework do a better job at ensuring optimal behavior.

The only thing outstanding is doing a bit of (human) work with the README.md and then we should be good to register!

v0.1.0 is out on general :partying_face:

ReactantNitro.jl now integrates with notebook environments via HTML tables, progress bars, and some basic plotting via Makie without requiring a logger. Shown below is KaimonSlate.jl:

We internally keep track of each validation, but not each training step. The principle is provide things to the user which provide the most value while taking the least resources and let experiment logger backends handle the rest. In the future we could handle this through an expanded experiment logger interface to pull the needed data.

One caveat with reactivity; neither Pluto.jl or KaimonSlate.jl understands that when you update a method that dispatches on your experiment type, that makes the cells that instantiate a Nitro with an instance of the experiment type stale. You need to make the dependency structure explicit:

# Cell A
compile_hook = begin
  ReactantNitro.forward(::MnistMLP, model, ps, st; img) = Lux.apply(model, img, ps, st)
  
  function ReactantNitro.loss(e::MnistMLP, logits; label)
      smoothed = (1f0 - e.smoothing) .* label .+ e.smoothing / 10f0
      return -sum(smoothed .* logsoftmax(logits; dims = 1)) / size(label, 2)
  end
end

# Cell B
data_hook = begin
  ...
end

# Cell C
host_hook = begin
  ...
end

# Cell D
begin
  # Explicit dependency on A, B, C
  compile_hook, host_hook, data_hook
  n = Nitro(...)
end

A standard Jupyter notebook style model works fine, and of course REPL + Revise.jl is completely seamless.

This is great! I want to play around with it in a private project soon. Really cool!

This is the kind of project I think the Julia space needs more of!

I accidentally started a run today for 50 epochs (default) when I should have done far fewer. Stopped the run and started the new one, no re-compile required :smiling_face_with_sunglasses: No need to think, when you skip the compile it’s because it wasn’t needed and when you don’t its because something was invalidated.

v0.1.3 has been released with a ton of improvements to working with checkpoints and resuming / loading weights / device buffers. When working in the same experiment directory over many experiments, you can do things like :

  • Load the best weights from the most recent experiment: Nitro(e; weights=:latest => :best)
  • Load the best weights from all previous experiments: Nitro(e; weights=:all => :best)
  • Load the latest weights from the most recent experiment: Nitro(e; weights=:all => :latest)
    • You can do the same thing with the resume kwarg; resume was formerly checkpoint which still works but is deprecated. This makes the users intent clear: they intend to resume the training run which is more involved than just loading weights and optimizer state.
  • If you use an experiment directory per experiment, there are no behavior changes and everything continues to work the way it did before. There was a bug when re-using the same experiment directory using topk, previously it did topk over all previous experiments and the current. Now you can control the behavior by choosing scope=:run (default) or scope=:all. Only checkpoints with a matching metric key are considered.

More improvements:

  • You can now also disable always keeping the most recent checkpoint with keep_latest=false.
  • restore_optimizer flag is provided for when you use weights= incase for some reason you also want the optimizer state (default false)
    • A logical next step would be making this do something for resume=, it would default to true there of course.
  • restore_best option added for train! so you can ensure the best checkpoint gets loaded into your Nitro at the end of training.

v0.1.8 is out; since v0.1.3 there have been quite a few useful additions and bugfixes:

  • Support for specifying Reactant @compile options via compile_options in Nitro; you can now take full advantage of the various optimizations Reactant/Enzyme-JAX/XLA offers such as raise_first=true for Polygeist and XLA autotuning flags: Compile options · ReactantNitro.jl
  • ~2% training speedup by removing a sync point in the main training loop
  • The remaining gaps in cache invalidation such as extensions defining a method that dispatches on ::AnyTracedRArray have been eliminated.
  • Fixed a family of device memory leaks and added tests that ensure that no device memory that ReactantNitro is responsible for leaks in the future.
  • TF SavedModel export via a PythonCall extension

I’m also writing an extremely detailed blog post about using ReactantNitro and ReactantServer from the perspective of a long time PyTorch user. It contains an end-to-end example showing how Reactant can effectively handle hybrid workloads with larger vision encoders feeding into a smaller dynamics module with performance comparisons against PyTorch. I found the performance gap much smaller than I previously measured, but Reactant was ahead by 10-20% on hybrid CNN+NODE problems and more than ~50% for pure NODE problems sweeping through all of the torch.compile options. My main takeaway is that Reactant has competitive performance for modern ML workloads today, and there is a huge amount of potential going forward with closer integration with SciML and all of the optimization work going into making these sorts of mixed workloads more powerful on the compiler side.

Thank you for your work !
Do you think it will support disk loading of models like llama, imposing a fix number of layer to be charged at a time ?

If we are talking about training, I think Reactant/XLA would have to support some sort of weight streaming during execution. We pass control to it when we call a thunk it produced; assuming we are talking about whole program optimization still, that means it has to be handled on the XLA side. If we are talking about breaking a program into multiple programs and moving the weights around before we execute each one then yes, that would be possible to support directly in Nitro.

v0.1.9 is out with syntactic sugar for metrics + switch to PHILOX by default for RNG since it’s fast and the default almost everywhere. It also includes a workaround for a bug in Reactants PJRT handling of RNG with a mesh, so multi device training is working properly again. Comprehensive tests around mesh/states were added to prevent any future regressions.

function ReactantNitro.metrics(::Classifier, logit; label)
    p = sigmoid.(vec(logit))                  # (B,) predicted probability
    y = vec(label) .> 0.5                     # (B,) true class
    return (;
        accuracy = sum((p .> 0.5) .== y) => length(y),
        worst    = maximum(abs.(p .- y)) => :max,
        ranking  = (; score = p => :concat,
                      label = y => :concat,
                      n_pos = count(y) => :sum),
    )
end

function ReactantNitro.finalize_metrics(::Classifier, acc, split)
    (; score, label, n_pos) = acc.ranking
    n_neg = length(label) - n_pos
    r = tiedrank(score)                       # StatsBase: ties share the average rank
    auroc = (sum(r[label]) - n_pos * (n_pos + 1) / 2) / (n_pos * n_neg)
    return (; acc.accuracy, acc.worst, auroc)
end

More examples here Metrics · ReactantNitro.jl including binned AUROC and R^2. The previous syntax still works and will be supported until 1.0.0.

I just meant inference don’t worry I know for training upfront things are needed.

@csvance so we haven’t set up nice handlers for this in reactant, but you should be able to use a julia_callback/etc to ingest runtime data [eg from fileio/stream input/etc] into a compiled reactant fn