FlexUnits.jl 0.3.0, Support for Unitful-like inference

FlexUnits.jl

Aqua QA
Build Status
Coverage Status

FlexUnits.jl v0.3.0 is a major new release that bridges the gap between Unitful.jl and DynamicQuantities.jl. As many are aware, Unitful.jl exhibits superior performance when units can be be inferred at parse time, but struggles when the compiler cannot infer units inside a function, forcing it to perform expensive run-time dispatch. Another package, DynamicQuantities.jl takes an alternative approach, where all dimensions can be represented by a single type. Dimension operations must be performed at run-time, but this approach is much less expensive than run-time dispatch, making it a safer option overall.

FlexUnits.jl bridges the gap between these two packages. With an API that closely resembles Unitful, FlexUnits now enables static units and dimensions by default where dimensions are a parameter. However, promotion rules convert quantities with static dimensions to ones with dynamic dimensions if multiple values must be represented in a single type (such as collecting multiple quantities with different units inside a vector or dictionary). This retains the high-performance behaviour of Unitful.jl when units are known at compile time, but often falls back to the performance of DynanicQuantity.jl if they can’t be inferred. In the first set of benchmarks, we see that FlexUnits.jl and DynamicQuantities.jl vastly outperform Unitful.jl (by more than 100x) when units cannot be inferred.

using FlexUnits
using .UnitRegistry
import DynamicQuantities
import Unitful
using BenchmarkTools

v1uni  = [1.0*Unitful.u"m/s", 1.0*Unitful.u"J/kg", 1.0*Unitful.u"A/V"]
v1dyn  = [1.0*DynamicQuantities.u"m/s", 1.0*DynamicQuantities.u"J/kg", 1.0*DynamicQuantities.u"A/V"]
v1flex = [1.0u"m/s", 1.0u"J/kg", 1.0u"A/V"]

@btime sum(x->x^0.0, $v1uni)
  7.575 μs (86 allocations: 3.92 KiB)
@btime sum(x->x^0.0, $v1dyn)
  41.667 ns (0 allocations: 0 bytes)
@btime sum(x->x^0.0, $v1flex)
  27.209 ns (0 allocations: 0 bytes)

In the second example, we see that FlexUnits.jl and Unitful.jl outperform DynanicQuantities.jl when units can be inferred by the compiler.

t1uni  = [1.0*Unitful.u"m/s", 1.0*Unitful.u"m/s", 1.0*Unitful.u"m/s"]
t1dyn  = [1.0*DynamicQuantities.u"m/s", 1.0*DynamicQuantities.u"m/s", 1.0*DynamicQuantities.u"m/s"]
t1flex = [1.0u"m/s", 1.0u"m/s", 1.0u"m/s"]

@btime sum(x->x*x, $t1uni)
  2.900 ns (0 allocations: 0 bytes)
@btime sum(x->x*x, $t1dyn)
  7.800 ns (0 allocations: 0 bytes)
@btime sum(x->x*x, $t1flex)
  2.900 ns (0 allocations: 0 bytes)

While this performance boost over DynamicQuantities.jl isn’t as dramatic as the previous boost over Unitful.jl, it is still significant. However, the benefits don’t stop here.

One major difference from Unitful.jl is that FlexUnits.jl will always convert to base units before performing a calculation. This prevents over-specialization because entities like pressure or mass flow will only ever have one unit associated with them. This can result in more successful attempts to statically resolve units. As an example, let us consider an iterative approach to solving the Peng-robinson equation of state for volume. Pressure can be explicitly solved, but solving for volume requires solving a cubic equation. This can be solved directly, but for the sake of illustration, we solve it iteratively in this function

function pressure(state)
    (T, V) = (state.T, state.V)

    R = state.R
    (a, b) = (state.a, state.b)
    α  = f_alpha(state)
    r2 = sqrt(2)
    Ī”  = (-1+r2, -1-r2)
    P  = R*T/(V-b) - (α*a)/((V-Ī”[1]*b)*(V-Ī”[2]*b))
    return P
end

function volume(state)
    (P, T) = (state.P, state.T)
    R = state.R
    V = R*T/P

    #Use the residual error of the ideal gas law to predict V and iterate
    for ii in 1:N_ITER[]
        Ph = pressure(state)
        Zr = Ph/P #Residual compressibility factor
        V  = V*Zr #Use compressibility to predict volume at P
    end

    return V 
end

The full code can be found in the test folder in this repo, but the main issue here is that if the state variable contains Unitful quantities, it will struggle with statically resolving for V when it is reassigned. If we run the benchmarks with 10 iterations, with Float64 values along with quantitites from each of the three packages, we see the following results:

No Units (Baseline)       45.369 ns (0 allocations: 0 bytes)
Static Unitful.jl         919.048 ns (21 allocations: 336 bytes)
Static DynamicQ.jl        273.622 ns (0 allocations: 0 bytes)
Static FlexUnits.jl       45.579 ns (0 allocations: 0 bytes)

Here, Unitful.jl is worse than DynamicQuantities, even though units should be statically inferrable while FlexUnits.jl is the only package that runs without overhead when compared to the baseline Float64 implementation. Examining the results from the Unitful.jl implementation offers further explanation.

julia> @btime volume($uf_t)
  653.125 ns (21 allocations: 336 bytes)
3.4362314782993218e-16 J^11 m^-30 mol^-1 Pa^-11

The units of V are evolving into an ever-growing monstrosity that still resolves to molar volumes. If we re-wrote the ā€œvolumeā€ function to store the the type of V and convert to this type every iteration, we get much more reasonable results and zero overhead.

julia> @btime volume($uf_t)
  48.081 ns (0 allocations: 0 bytes)
3.4362314782993218e-16 J mol^-1 Pa^-1

However, this required modifying existing code to fit Unitful.jl which may not be feasible when trying to apply units to other peoples’ code. One last trick that this new FlexUnits.jl offers is the ability to apply function barriers to dynamic quantities. If the argument states is a vector, requiring a single type, we can see that DynamicQuantities and FlexUnits outperform Unitful.

Dynamic Unitful           5.880 μs (212 allocations: 3.31 KiB)
Dynamic DynamicQ          281.102 ns (0 allocations: 0 bytes)
Dynamic FlexUnits         232.012 ns (0 allocations: 0 bytes)

However, one can apply a function barrier in FlexUnits that explicitly converts the dynamic units into static ones before running the calculations

julia> @btime volume_function_barrier($fl_v)
  41.513 ns (0 allocations: 0 bytes)
3.4362314782993218e-16 m³/mol

While this isn’t exactly zero overhead, it is very close. Moreover, only the internals of this function are specialized, avoiding excessive specialization in other areas of the code; this would likely improve compile-time and even run-time performance.

This update was the largest enhancement on the roadmap, so the package should be much more stable from here on out. Now that it’s at a point where it is arguably useful and fairly stable, I will be working on the documentation shortly.

17 Likes

Glad to see that someone took on that challenge! As someone managing both big homogeneous arrays and small heterogenous arrays, I appreciate it.

As a side note, DynamicQuantities.jl performs a bit better with QuantityArrays

julia> t1dyn_arr = QuantityArray(t1dyn)
3-element QuantityArray(::Vector{Float64}, ::Quantity{Float64, Dimensions{FRInt32}}):
 1.0 m s⁻¹
 1.0 m s⁻¹
 1.0 m s⁻¹

julia> @btime sum(x->x^0.0, $t1dyn)
  49.089 ns (0 allocations: 0 bytes)
3.0

julia> @btime sum(x->x^0.0, $t1dyn_arr)
  39.960 ns (0 allocations: 0 bytes)
3.0

My conclusions from evaluating DynamicQuantities are similar to yours.

  • DynamicQuantities is broadly very good and very flexible, within 2X of the best performance in general.
  • The use cases where Unitful is significantly better are quite narrow. Maybe if I had to multiply small heterogeneous tuples, or covariance matrices? But that was discussed long ago. PRs were attempted without getting anywhere.

AFAIU the SciML ecosystem reached similar conclusions and moved on to DynamicQuantities.

I think FlexUnits.jl is conceptually appealing, unifying both approaches, but my main concern would be whether the increased implementation complexity from supporting both approaches makes it worthwhile compared to DynamicQuantities.jl.

One thing that would prevent us from moving to DynamicQuantities.jl is that we have missing values (and other exotic values, relying on union splitting). AFAICT this is not supported over there. I’d be curious to see a FlexUnits.jl benchmark over a large homogeneous Vector{Union{some_unitful_type, Missing}}

Actually, due to how this package was designed around flexibility, it was a fairly simple exercise to add static unit support. I applied what I learned from my difficulties in contributing to DynamicQuantities.jl when I first designed this package and it seemed to have paid off because I think I spent less than 20 hours on this over the Christmas break.

Anyway, I do think that DynamicQuantities actually supports more types than Unitful. In Unitful, a Quantity must contain a number and a unit, which is very restrictive. In DynamicQuantities, the default Quantity type is just as restrictive, but you can have a GenericQuantity that an contain anything inside its value. A FlexUnits Quantity is essentially a GenericQuantity in DynamicQuantities; it can contain any exotic value you want (I often have Quantity types with distributions as values). By having a single generic Quantity, I managed to sidestep a lot of the ambiguity issues in DynamicQuantities, and a lot of the invalidations that happen there as well. The drawback is that you can’t use a FlexUnit Quantity in an algorithm that only takes ā€œNumberā€ but I find in general that’s not a great idea anyway. Even Base Julia methods with generic type signatures can choke on Quantities from any of these three packages.

2 Likes

One set of benchmarks you may want to also check is uconvert. I find symbolic units in DynamicQuantities to be quite slow to the point where it actually became a bottleneck in some of my applications when customers demanded intermediate results in the units of measure they’re used to dealing with. Units in FlexUnits are much simpler objects than SymbolicUnits with much better uconvert performance (around 20-80x faster in most cases).

S4.1) upreferred

Unitful:          395.000 ns (3 allocations: 7.90 KiB)
DynamicQ:         23.700 μs (3 allocations: 39.13 KiB)
FlexU:            279.793 ns (3 allocations: 7.90 KiB)

S4.2) ustrip

Unitful:          298.830 ns (3 allocations: 7.90 KiB)
DynamicQ:         19.900 μs (3 allocations: 7.88 KiB)
FlexU:            273.783 ns (4 allocations: 7.94 KiB)

S4.3) uconvert to arbitrary units

Unitful:          348.087 ns (3 allocations: 7.90 KiB)
DynamicQ:         40.300 μs (3 allocations: 23.51 KiB)
FlexU:            2.862 μs (4 allocations: 31.36 KiB)
2 Likes

Currently, Unitful, FlexUnits, etc. all define their own uconvert function. Could we have an ā€œAbstractUnitsā€ package (or similar) that defines these, and so basically established a common unit trait interface?

Indeed, it makes total sense conceptually! Unitful (compile-time units) is a great ā€œdefaultā€ choice, the zero-cost abstraction; meanwhile, there clearly are some usecases for runtime units as well. And there’s no reason why we have to use different functions for them.

There was a long discussion years ago, and I drafted a proof-of-concept package – see Units interface package Ā· Issue #595 Ā· JuliaPhysics/Unitful.jl Ā· GitHub. But I don’t really have any usecase for runtime units – so I just continue to use compiletime Unitful. Maybe somebody who uses both would feel motivated to coordinate this further :slight_smile:

Ideally, if such a layer appears and supports stuff like ustrip(Unitful.m, 123*DynamicQuantities.m), most packages should define their functions using Unitful (comptime) units. Then the choice is up to the user: if they pass Unitful, everything remains zerocost and typestable; if they pass runtime units, it would work and have typical ā€œruntimeā€ performance (not zero cost, same as dynamicquantities).

FlexUnits.jl does have a Unitful.jl extension, where uconvert will output the appropriate quantity type based on the type of units in uconvert. If the unit is a Unitful object, the output is a Unitful quantity. However, I’m not that motivated to maintain it now that FlexUnits supports compile-time units that match Unitful in performance.

Oddly enough, I find myself using runtime units most of the time because I deal with MQTT message handlers where units of measure are embedded with the message and need to have ā€œuparseā€ applied to it. I have lots of applications where different customers have different units of measure on similar sensors. So I have to use ā€œuparseā€ everywhere which is slow and painful in Unitful. I only use ā€œcompile-timeā€ units on low-level functions, so what I needed FlexUnits.jl to do is efficiently funnel dynamic units to a point where I can use a function barrier to convert the values to compile-time units for low-level operations. This means that ā€œuparseā€ in FlexUnits.jl is always type-stable, but the string macro @u_str produces compile-time units by default.

So the packages handle missing in a few different ways. Unitful and DynamicQuantities make any action with ā€œmissingā€ into a ā€œmissingā€ itself. FlexUnits keeps the units but makes the value missing. Nevertheless, the benchmarks for such a structure can be performed as follows:

vr = randn(1000)
vum = [vr.*Unitful.u"m/m"; missing]
vdm = [vr.*DynamicQuantities.u"m/m"; missing]
vfm = [vr.*UnitRegistry.u"";missing]

julia> @btime sum($vum);
  400.000 ns (0 allocations: 0 bytes)
julia> @btime sum($vdm);
  1.920 μs (0 allocations: 0 bytes)
julia> @btime sum($vfm);
  403.015 ns (0 allocations: 0 bytes)

So FlexUnits behaves more like Unitful here. If you want missing values inside quantities, Unitful cannot do this, but DynamicQuantities and FlexUnits can.

vdm = QuantityArray(vm, DynamicQuantities.dimension(DynamicQuantities.u"m"))
vfm = Quantity{eltype(vm)}.(vm.*UnitRegistry.u"m")

julia> @btime typeof(sum($vdm))
  2.022 μs (0 allocations: 0 bytes)
julia> @btime typeof(sum($vfm))
  403.000 ns (0 allocations: 0 bytes)

Note that you need to convert to Quantity{eltype(vm)} here because mapping and broadcasting don’t promote narrowly enough right now.

Maybe now that we have several solid unit-packages in the field, there would be renewed interest in an abstract interface package.

It would certainly help users if they don’t have to worry about which (e.g.) ustrip and uconvert they have to use to get cross-coverage via package extensions. It might also make it possible to write unit-aware algorithms without depending on any specific unit package.

The approach has been very successful with Tables.jl, after all, which is supported by several, conceptually very different, table-like packages.

1 Like

See UnitsBase.jl tests (UnitsBase.jl/test/runtests.jl at master Ā· JuliaAPlavin/UnitsBase.jl Ā· GitHub) for a few examples of what is easily possible even without any buy-in from packages that provide units. The same unit and ustrip functions work for any value with units (even mixing different packages); arithmetics also works for mixed unit types.
Maybe I should just register that package as-is? :slight_smile:

Oh I see what you mean. That would have been really helpful when I made that Unitful extension. While it wasn’t too much work (I contributed to the DynamicQuantities Unitful extension before), I think managing those conflicts was harder than modifying FlexUnits to support static units :laughing:

This update is honestly legendary. When the compiler can fully infer the unit types, FlexUnits is now right up there with Unitful, and even slightly faster than Unitful in many of the benchmarks.

I also really like how this announcement is written: the first paragraph clearly compares the existing unit libraries and pinpoints the current gap, and the second explains how FlexUnits closes that gap, backed by benchmarks. That makes the case for adoption very compelling. I’m excited to try FlexUnits in my own projects. :smiling_face_with_three_hearts: :wink:

1 Like

It’s up to the package maintainers, of course, but maybe they might be interested in buying into UnitsBase? Then the package-specific code could be managed with in the packages, etc.

You’re a part of the JuliaPhysics org, correct? I think if you guys adopt UnitsBase for Unitful and DynamicQuantities, it will generate enough buy-in. I’m a long-time user of Unitful and I enjoy it’s API design. In fact, FlexUnits was born out of a desire to have the flexible performance of DynamicQuantities but look and feel more like Unitful (which is more familiar and better designed IMO). I’m really trying to follow Unitful’s convention where reasonable (and StaticUnits/StaticDims pushes FlexUnits even closer to it) because of how widely it’s used.

Yes, I’ll definitely look where we can adopt UnitsBase in the projects I’m involved in.

Is it possible to type function arguments, something like this:

f(a::Temperature) = #do something

This doesn’t work as written but is there a Temperature type (or mass, distance, time, etc.) in FlexUnits that can be used instead?

I find it helpful to add these types to function declarations so users know what types of arguments to supply. Didn’t see this discussed in the documentation.

1 Like

This isn’t something that exists currently because there are so many kinds of dimensions we might have to consider. Moreover, you could only achieve this for static quantities, and you might want to consider both unitful quantities and dimensional ones (all the math is done on dimensional quantities). That being said, this is pretty doable on the user’s end if this is desirable:

julia> const StaticUnitOrDims{D} = Union{StaticDims{D},<:StaticUnits{D}} where D
Union{StaticDims{D}, var"#s15"} where {D, var"#s15"<:(StaticUnits{D})}

julia> const Temperature = Quantity{<:Any, <:StaticUnitOrDims{Dimensions(temperature=1)}}
Quantity{<:Any, <:Union{StaticDims{K}, var"#s15"} where var"#s15"<:(StaticUnits{K})}

julia> 1u"K" isa Temperature
true

julia> ubase([1,2,3]u"K") isa Temperature
true

julia> 1u"K" |> u"°C" isa Temperature
true

As for dispatch, we can create other dimension types like this and dispatch easily.

const StaticUnitOrDims{D} = Union{StaticDims{D},<:StaticUnits{D}} where D
const Temperature = Quantity{<:Any, <:StaticUnitOrDims{Dimensions(temperature=1)}}
const Mass = Quantity{<:Any, <:StaticUnitOrDims{Dimensions(mass=1)}}
const MolarFlux = Quantity{<:Any, <:StaticUnitOrDims{Dimensions(amount=1, length=-2, time=-1)}}

whatsit(x::Temperature) = "temperature"
whatsit(x::Mass) = "mass"
whatsit(x::MolarFlux) = "molar flux"

julia> whatsit(1u"kg")
"mass"

julia> whatsit(1u"°C")
"temperature"

julia> whatsit(1u"mol/(s*m^2)")
"molar flux"
1 Like