viralinstruction

Review: Julia trimming for Advent of Code 2025

Written 2025-12-05

Advent of Code is a great opportunity to play around with programming to learn something new. This year, I didn't want to try solving it in Rust or Zig again. Instead, I've test driven a few experimental features of Julia that I haven't yet dared to use production code: Trimming, JETLS and a few not-so-established packages.

This post is a review of trimming, in its current, experimental, state.

Table of contents
  1. What is trimming?
  2. Review of trimming
  3. Conclusion

What is trimming?

The core goal of Julia is to be a high-level, dynamic language with the runtime speed of a static language. It achieves this by bundling a JIT compiler with the runtime, which identifies static subgraphs of the call graph, and compiles these static parts to native code. The language semantics enable users to write code that can be statically resolved ("inferrible")[1], while also allowing uninferrible code (which I'll call "dynamic"). Inferrible and dynamic code can be mixed freely and effortlessly.

The gambit is that most runtime is spent in small sections of code that can easily be written to be inferrible, whereas dynamism is mostly useful for high level program logic, such as interactive programming, which is not performance sensitive.

By being a static/dynamic hybrid[2], Julia draws advantages from both kinds of languages, but it also suffers from some of the weaknesses of both. Among the many weaknesses caused by its hybrid nature is that Julia is hard to deploy. Like a dynamic language, running a Julia program requires a preinstalled runtime, and because Julia is relatively obscure compared to, say, Python, you can't rely on your users already having it installed.[3] Another infamous downside of its hybrid nature is that Julia's JIT compiles your program after you've launched it, making programs written in language unbearably laggy and mostly unsuitable for short-running tasks such as simple command line utilities[4].

Trimming is an attempt to provide an opt-in solution to both these problems, and is intended to be used in those circumstances where these issues are pressing, such as command-line utilities. Trimming is based on the observation that, if the static subset of your call graph encompasses your entire program, Julia ought to be able to compile the whole program to a single, static binary. Trimming has been worked on by the core devs for several years, and recently, their work has born fruit: An early experimental version landed in the 1.12 release and can now be used.

From the outset, I've been quite skeptical of the whole concept. My concern is that, because Julia encourages a free mix of inferrible and dynamic code, dynamic code is strewn throughout all Julia code, and this precludes trimming, since the entire call graph must be inferrible. By design, the language semantically blurs the inferrible and dynamic parts of a program, and does not allow easy separation of the two, or even identification of which is which. Dynamism may be as subtle as a single struct field access somewhere. What's worse, dynamism is built into lots of Julia code on an API level, and so literally cannot be avoided at this point.

So, what exactly is the implication of a trimmable Julia program? Should users finely comb their own code, and that of their dependencies, to find and remove all dynamism? Even if they succeed, how can maintainers prevent dynamism from creeping back in a package, or their dependencies? Or is introducing any dynamism considered a breaking change now, even though dynamism is a core part of Julia's identity? And how would staticness be achieved in the first place when the user needs to interact with interfaces that are fundamentally dynamic, for example, those making use of untyped objects?

Suffice it to say I curious to give trimming a try to see how these issues worked out in practice. Advent of Code provides the best possible conditions for trimming, since solutions are typically small and self-contained, and with few dependencies.

If you're interested, my code can be found here.

Review of trimming

Please note: At the timing of writing, trimming is still an experimental feature in active development and likely to improve significantly before being stabilized.

Julia code is trimmed by a custom Julia compiler called JuliaC, which remarkably can be easily installed as an ordinary Julia package. Impressive!

When trimming, if your code contain uninferrible code, JuliaC will throw an error like below:

◒ Compiling...Verifier error #1: unresolved call from statement (Base.memoryrefget(Base.memoryrefnew(Base.getfield(%new()::Vector{Any}, :ref)::MemoryRef{Any}, 1, false::Bool)::MemoryRef{Any}, :not_atomic, false::Bool)::Any)(data::MemoryViews.ImmutableMemoryView{UInt8})::Any
Stacktrace:
 [1] solve(day::AoC2025.Day, data::MemoryViews.ImmutableMemoryView{UInt8})
   @ AoC2025 ~/code/AoC2025/src/AoC2025.jl:80
◐ Compiling...(::AoC2025.var"#main##0#main##1")(day_data::@NamedTuple{day::AoC2025.Day, data::Union{Nothing, Vector{UInt8}}})
   @ AoC2025 ~/code/AoC2025/src/AoC2025.jl:176 [inlined]
 [3] iterate(::Base.Generator{Vector{@NamedTuple{day::AoC2025.Day, data::Union{Nothing, Vector{UInt8}}}}, AoC2025.var"#main##0#main##1"})
   @ Base generator.jl:48 [inlined]
 [4] _collect(c::Vector{@NamedTuple{day::AoC2025.Day, data::Union{Nothing, Vector{UInt8}}}}, itr::Base.Generator{Vector{@NamedTuple{day::AoC2025.Day, data::Union{Nothing, Vector{UInt8}}}}, AoC2025.var"#main##0#main##1"}, ::Base.EltypeUnknown, isz::Base.HasShape{1})
   @ Base array.jl:810
 [5] collect_similar(cont::Vector{@NamedTuple{day::AoC2025.Day, data::Union{Nothing, Vector{UInt8}}}}, itr::Base.Generator{Vector{@NamedTuple{day::AoC2025.Day, data::Union{Nothing, Vector{UInt8}}}}, AoC2025.var"#main##0#main##1"})
   @ Base array.jl:732 [inlined]
 [6] map(f::AoC2025.var"#main##0#main##1", A::Vector{@NamedTuple{day::AoC2025.Day, data::Union{Nothing, Vector{UInt8}}}})
   @ Base abstractarray.jl:3372 [inlined]
 [7] main(ARGS::Vector{String})
   @ AoC2025 ~/code/AoC2025/src/AoC2025.jl:171
 [8] _main(argc::Int32, argv::Ptr{Ptr{Int8}})
   @ Main ~/.julia/packages/JuliaC/L13jU/src/scripts/juliac-buildscript.jl:73

This is fairly readable, if you're familiar with Julia's type annotation and how stack traces look. It could be better with some colors, and line spacing separating the unresolvable call from the stack trace below it.

In contrast to normal static languages, the compiler will not warn you if your code contains type errors.

This may seem weird - why does the compiler not error, or even warn, if it knows there is a type error? The rationale is that, in dynamic languages, it is common to write code that contain code paths with type errors. What matters is that there is no runtime reachable path that triggers these type errors - a far more permissible requirement than that of static languages, who demand that the compiler must know statically there are no such paths.

For example, we could write the following code:

function @main(ARGS)
    n = parse(Int, ARGS[1])
    n += isodd(n) 
    return foo(n)
end

@noinline foo(n) = n + (isodd(n) ? "" : 1)

Here, we can see that foo is only ever called with even numbers, and so there is never any circumstance where foo attempts to add a string to an integer, and so the type error is not possible at runtime. However, the poor, dumb compiler can't make the same reasoning, and this code would not compile in a normal static language.

If JuliaC were to disallow code with possible type errors, like the above code, it would mean that lots of working, inferrible Julia code could not be compiled. In effect, it would mean that trimmable Julia code would have radically different semantics from normal Julia code. Having two different versions of Julia with conflicting semantics would risk splitting the Julia ecosystem.

I agree that, given the above reasoning, the decision to allow type errors at compile time is unavoidable. However, I must say it's quite annoying when type errors are not caught at compile time when writing a trimmed app. It's way more annoying than when writing some generic Julia library, because, when writing an executable, the observability and the possible execution paths are both more limited, and so statically preventing type errors is both more important, and more feasible.

The current state of affairs isn't the last word on static analysis in Julia, and I'm convinced that this area will see improvement over the next years.

If a type error is hit at runtime, it exits the program with an error like this:

fatal: error thrown and no exception handler available.
Core.MethodError(f=Base.var"#+"(), args=(1, ""), world=0x000000000000976d)

Not the prettiest, or most readable error. It says fatal: error thrown and no exception handler available.. I would guess that Julia's normal exception handling is one of those critical components of Julia itself that unfortunately contains dynamic APIs, and therefore can't be trimmed - probably, JuliaC has a separate, inferrible implementation of exceptions.

The error above is also poorly formatted and a little hard to read. If any of the arguments is a large array, the error will contain the whole array, flooding your terminal.

Exception handling code is not unique in being untrimmable. I would occasionally run into some basic function that couldn't compile - such as left padding a string - because some distant callee had some dynamic behaviour. Other untrimmable basic functionality includes printing to stdout[5], or printing in colors using StyledStrings. There is also currently no command line argument parsing library that is trimmable. These examples are particularly unfortunate, since command-line applications are the major use case for trimming, and these examples are exactly what CLI apps require.

I was pleasantly surprised to learn that adding and using dependencies worked perfectly and required no special handling. This of course requires that the code you use from your dependencies is inferrible, but other than StyledStrings or argument parsing packages, that was not a problem for the packages I used.

While writing solutions to the advent challenges, I increasingly got a feeling that Julia misses some important language features that static languages have precisely to allow static compilation of otherwise dynamic behaviour. For example, in Rust, one pattern to deal with the runtime uncertainty of the type of a value is using an enum, which is similar to a Julian union. However, Julian unions don't carry the same guarantees about inference that Rust's enums do.

For example, in one part of my code, a variable that was a large union was suddenly inferred to be Any, which then stopped compilation. How can a union suddenly be "upgraded" to Any? Because Julia intentionally doesn't have any semantics that guarantee some upper bound on an inferred type. In theory, inference is allowed to fail. In practice, inference only really fails for edge cases, such as unions whose number of possible types crosses some threshold.

I could work around the issue by wrapping the union in a struct, which would change the compiler's heuristics about inference of unions. But requiring users to exploit internal compiler heuristics is, of course, unlearnable, brittle, and completely unreasonable. Instead, I ended up having to change each of the possible values to a stringified version of itself, which I consider bad design.

A similar issue is how to handle a runtime function. In C, you'd not give a shit about type safety and pass a pointer. In Rust, you'd pass a Box<dyn Fn(A) -> B>. In trimmed Julia, there is simply no way to do it.

In theory, these two issues can be solved. For the former problem, Julia could document its inference behaviour, or, preferably, introduce sum types to the language, for whom an upper bound on inference is more easily guaranteed[6]. For the latter, the core devs are already working on a solution.

More fundamentally, issues like these are an unfortunate consequence of the seamless static/dynamic blend of Julia - the seamlessness allows inferrible code to drift unnoticed right up to the edge of what's possible to infer, and then a minor refactor can drive it over the edge, at which point the code can't compile. By design, Julia can't put any guardrails around the edges of what's possible to infer, without substantially changing the language's semantics.

I wish I could say that my limited experience using trimming has calmed these concerns, but I can't. When trying to trim my app using the current alpha release of Julia, 12 new compiler errors appeared, which all seem to come from internal code in Base Julia that no longer is inferrible. You could say that bugs are expected in an alpha release, but it's not clear to me that a sudden failure of JuliaC is even a bug, since dynamism is generally accepted in Julia. Even if it was unambiguously a breaking change, there is, as far as I know, no infrastructure in place to prevent these breakages. Almost no code in Base is tested for trimmability.

My personal opinion is that I wouldn't mind seeing a static dialect of the language that can interoperate with the normal dynamic/static hybrid dialect. That is, users could prototype code in the hybrid dialect, and then when it's production ready, move it to the static dialect, and then lock it in. I don't think this would reintroduce the "two language problem", because the static dialect would still be callable and introspectable from dynamic code, because dynamic code could so easily transition to being static, and because the actual difference between the two dialects would be minimal. The static dialect could even be opted in on a per-function level.

Anyway, back to the UX of trimming.

Compile times are ~10 seconds, for my small AoC binary which has a few dependencies. I don't know how compile times scale for larger packages, but I know that JuliaHub uses trimming for packages with hundreds of dependencies. While the compile times are not great, I don't think it's a big issue, because development will presumably happen in an interactive session where the code can be loaded run and tested in the REPL. Because JuliaC users - unlike users of ordinary static languages - are not forced to develop in a change-compile-run loop, JuliaC itself need only be run rarely. It certainly didn't feel like a usability issue.

The resulting binary has a startup time of around 25 ms. This latency is too small to notice on a human scale - the executable I produced runs perceptually instantly. Improving the overhead would be very far down my priority list compared to the other issues above, if I was a JuliaC developer. Runtime performance is also excellent, as I would expect from Julia - the first few AoC challenges, from argument parsing to exit, takes about 500 microseconds each, on average, so even the small 25 ms startup time dominates.

My binary was around 3 MB in size, which I found impressively small. However, the 3 MB executable leans on ~91 MB of dynamic libraries that come bundled with it - presumably, most of the runtime resides there. Some more work could be done in that area - about 1/3rd of the total 94 MB is BLAS, which my app doesn't even use.

That means there's more work to be done on improving distribution of trimmed apps - ideally, unused binary dependencies could be cut, and the used ones possibly statically linked in order to produce a relocatable executable. Also, as far as I know, there is no way to install a JuliaC app through Pkg's application management API - that would also be cool. At the rate Julia's deployment story has improved the last few years, I'm optimistic these issues will be worked out within the next few years.

Julia currently doesn't have a good IDE. I can't articulate why, but I missed a good IDE more when writing a trimmed app than I normally do. It might have something to do with my desire for more static guarantees for an app, or it could be because I didn't particularly care for the errors produced by JuliaC, and would much prefer to get better errors from an IDE.

One of the Julia compiler devs is currently working on a Julia IDE, called JETLS, which is available as an early prerelease. This post was originally also intended to also be a review of JETLS, but I found the current state of JETLS to be incomplete and buggy, so a review would be meaningless at this point. I'll be reviewing this in maybe half a year when it's further along.

Through a broader lens, the state of trimming is emblematic of Julia's development: The core developers continually put themselves under extremely difficult design constrains, by insisting on conflicting requirements:

In this post, I've detailed how the language is conflicted in that it aims to compile to native binaries, while still retaining the semantics of an interactive, dynamic language.

Or, I could mention how the language wants to feel as interactive and dynamic as Python or R, yet be as fast as C - a duality which, beside the inherent design tradeoffs, also implies having an enormous API surface spanning from low-level memory optimizations to convenient, high-level multidimensional array operations.

Or, how it wants to have an "everything runtime", a natively multithreaded language using async IO by default, but which also have a garbage collector, and a JIT compiler with hot code reloading, all of which must work run asynchronously with running, native code[7].

Building a coherent, functional language under all these conflicting requirements floats somewhere between being hard work, an open research question, and downright impossible - to which the Julia core devs' response is: "Let's get to work". Because they are greedy - they want it all.

The result is a weird situation where the resources that goes into developing and running Julia are staggering - one core dev ballpark estimated it to cost 10 million dollars a year, not counting volunteer labor - and yet the outcome is a Julia that still after thirteen years feel a little bit broken and unfinished. Like an advanced, futuristic robot that somehow keeps falling over.

The reaction of users generally come in two flavors: Those who are deeply impressed with the ambition and aims, and those who are disillusioned with the implementation. Consequently, Julia tends to garner both true fanboys and haters. Often, people start in the first camp, and slowly move towards the second. As you can tell, I fall somewhat in both camps.

Conclusion

Trimming is a cool and impressive feature, which does work exactly as advertised. However, at this point it has poor usability and a janky feel, like an early alpha release of a static language. Despite the jankiness, the prospect of small, low-latency, shippable Julia executables is just too good to not at least try it out.

My enthusiasm is soured somewhat by the looming issue that trimming is undermined by Julia's dynamic semantics. The Julia community is still undecided about how exactly the language relates to its own staticness: On the surface, the language insists it's entirely dynamic, even as that assertion makes less and less contact with the reality of its implementation[8]. I believe the community and ecosystem needs a wake-up call in this area. Trimming surfaces the contradictions of the current stance and makes them unignorable - and we're all better off for it. If nothing else, Julia users trying out trimming and facing these problems will help push the ecosystem in a healthier direction.

But, until the underlying semantic and tooling issues are resolved, definitely don't use trimming in production code.

[1] Technically, I'm not sure these are quite the same thing. A call graph may be statically resolved even if the type of some variables cannot be fully inferred, however, all inferrible code is statically resolvable. However, the distinction matters less for Julia users, who mostly should aim to make their code inferrible, and this way achieve a resolvable call graph. Hence, I'll use the terms 'static' and 'inferrible' interchangeably here.
[2] Semantically, Julia is just a dynamic language. It is the implementation that is a static/dynamic hybrid. Well, mostly[8].
[3] Julia has many, many other issues with deployability, such as the lack of a good command line argument parser, and until recently, the ability to create applications. However, these minor limitations are a matter of missing features, whereas the one mentioned above are baked into Julia's design, so I'm much less worried about the former. Also, recent work has improved the minor issues significantly - e.g. the latest version of Julia allows easy creation of apps.
[4] This has also been greatly improved. For example, my Julia code formatter (written in Julia) runs from command line in 400 milliseconds, including the JIT compilation. However, compiler performance is mercurial because there are way too many ways to unintentionally tank it. For larger projects, which have to pull in lots of dependencies, keeping Julia reasonably responsive, especially over time, is almost infeasible.
[5] This is because stdout and stderr in Julia are untyped global variables, so their use cannot be inferred. You can work around this by printing directly to Core.stdout instead of stdout, but that is a bit of a hack. It's a good example of how hard it is to retrofit staticness to a dynamic language: It's part of the API of printing that the sink can be dynamically changed, e.g. to capture stdout.
[6] This is because unions propagate their uncertainty, but sum types don't. If you have a x::Union{A, B} and have f(::A)::Union{X, Y} and f(::B)::Union{Z, W}, then f(x)::Union{X, Y, Z, W}. The issue here is automatic propagation: In most cases, code doesn't require a combinatorial product of types like this, and only a few types are actually expected. This mostly useless combinatorial explosion is also the reason Julia gives up on inference of big unions.
[7] These features compound to make the implementation of them harder. For example, how do you GC a multithreaded program if one of the threads is running a tight loop which cannot afford to 'check in' with the runtime? If Julia was interpreted and slow, or had a Python-style GIL, the developer's life would be much easier. It would also be easier if Julia was were content to just have good multithreading GC with native code, and otherwise be a boring, ahead of time compiled language - like Go opted for.
[8] Trimming makes the tension between the dynamic semantics and the static implementation uncomfortable for all the reasons outlined in this post, but the tension has produced many other fissures in the language. The ongoing discussion about invalidations is another example, and so is the inconvenient fact that Julia occasionally uses inference to determine the runtime value of objects.