Rust and numeric computation

That pre-RFC looked nice, but what is the need of seeling traits ? I mean understand the purpose is to forbid other crates to implement them, but what is the need in the specific case of Int and Float ?

By separating eventual numeric traits in core than numeric traits implemented in other crates is keeping the issue of API duplication described in my initial post: using traits from core and concurrent traits from other crates leads to have in the same crate functions implemented with one trait and its methods and functions asside with an other trait and different methods for the same thing, bringing confusion if not verbosity (because when 2 traits provide the same method in the same scope, one has to use <T as Trait>::method(...) syntax ...)

The purpose of sealing core::primitive::Integer and core::Primitive::Float specifically is to forbid implementations in other crates / on other types. This makes it possible to add new functionality to the traits as we add new functionality to the primitive types. Additionally, it permits the traits to represent the existing self-taking methods that rely on the types being Copy instead of switching to &self. The primary reason is that exactly mirroring existing functionality that exists over existing type sets is easier to justify and simpler to stabilize than trying create a richer trait system.

Furthermore, depending on how much we teach the coherency checker, both traits being #[sealed] #[fundamental] would allow impl<T: Integer> MyTrait for T and impl<T: Float> MyTrait for T to not overlap. This mutual exclusivity is actually a fairly big deal and what allows replacing macro-generated functionality across primitive types with generic functionality without causing coherency issues.

And yet further still, it's potentially desirable to have T: Integer bounds imply further where bounds on &T to provide the by-ref core::ops impls that exist on the primitive arithmetic types. Standard traits do not currently elaborate where bounds and are reasonably unlikely ever to, but (unstable) trait aliases (usable as bounds but not directly implementable) currently do. #[sealed] traits could reasonably elaborate where bounds (they have different semver compatibility reasoning), but trait aliases also confer the ability to potentially move shared bounds to a shared supertrait without breaking references to e.g. <_ as Integer>::from_ne_bytes since that's not writable with trait aliases.

It doesn't preclude adding a separate std::num hierarchy in the future if the project decides it's worth the effort; it still makes sense to be able to talk about the language primitive type sets {integer} and {float}.

13 Likes

And delay the release of 1.0 indefinitely?Have you ever shipped software?

4 Likes

Thanks for writing about an important topic @jimy-byerley.

These are the points I think are important - because of work on and with ndarray.

  • Generic literals for integers and floats would be a big quality of life upgrade, and it would be necessary for us to feel that numerics is a tier 1 feature in Rust

  • More flexibility in indexing and slicing syntax and traits, to enable multidimensional indexing and slicing. This probably needs work on the custom DST RFC as well. I can't help but think of Python here that enabled advanced indexing features in its syntax, that are never used in core Python (but numpy and the rest use it).

  • Specialization or overloading of some kind to enable calling specific functions for f32, f64 etc. (The solution I know I have today is the T: 'static bound and dispatch on TypeId.)

The Rust community has the RFC process and that's the way to go to make improvements in this area too. We need to be lucky to have contributors with time and interest in this area that can do this. I'm sorry I haven't been one of them.

6 Likes

(Or money to fund such contributors.)

shipped software ... discovered bugs, and introduced temporary regressions to patch it. I unfortunately did ^.^ but rust 1 is from 8 years ago and no solution landed in ever since ? I think that's mainly what left me think this feature was not a priority.

I agree that the numerics side of Rust leaves much to be desired. My personal pet peeve is the design of arithmetic operation traits (Add, Mul, MulAssign etc), which works horribly with generic code, since you need to implement basically all combinations of val OP val, &val OP val, val OP &val and &val OP &val to have reasonable expression writing ergonomics (and similarly for OpAssign). Worse, implementing all that stuff for numeric types is painful, but at least automatable with macros. But it's impossible to encapsulate those requirements in a trait bound, and one must repeat a significant amount of them, because there is currently no way to put a constraint on Self that is

for<'a> &'a Self: Add<Self, Output=Self>

You can technically write a trait with a where-bound like that, but it doesn't work as a modularization tool, because the bound will need to be explicitly repeated on every type and function using a generic with this restriction. Also, type checker is prone to barfing out some really bad errors when bounds of the form for<'a> &a T: Foo are present, mostly because of type checker's limitations than something inherently wrong with those bounds.

This is also why I think that trying to introduce more kitchen-sink traits to deal with ergonomics issues of numerics is a dead end. There is just no way to really express the semantics of numeric code in current Rust. At best you can restrict numeric traits to Sized + Copy + Freeze types with some #[fundamental] tactically sprinkled in, but it still won't work the way eigen templates work. These assumptions immediately exclude a large number of important potential applications, like heap-allocated BigInt, and you still can't write a simple arithmetic expression without hitting the sharp corners of by-value vs by-ref operations. On top of that you get more complex code, likely worse compile times, likely worse optimization.


That said, I'm a bit confused by your conclusion:

I get that using eigen is simpler, but C? There is literally nothing C can do that Rust can't do, better. C also doesn't have generics or overloading, and basically can't handle static array sizes, since arrays decay to pointers. You can always write Rust like you write C, even use macros, and you'll still be better off.

In fact, for scientific prototyping I wonder how many of your problems are self-inflicted. Why do you even need to be generic over integers and floats? It's a real issue for foundational libraries like nalgebra and ndarray, but end-user applications should just choose a couple of specific numeric types and stick with them. Done, all of your generic problems gone in one fell swoop.

A reasonable counterargument is "but what if I choose f64 but later decide that f32 is better for speed and is sufficiently precise", or vice versa, "what if I go with f32 but later realise I need the precision of f64". You still don't need to be generic in all your code, though. You can solve that problem by introducing a single typedef

type Float = f64;

and using exclusively that type for all your type bounds. If you need to swap your floating-point type, just change that typedef, done. You have the potential to get confusing errors if some duck-typed API turns out to be slightly different between f32 and f64, but that's still as good as you would have with C++ templates, and I doubt there would be many such differences.

Similarly, for your integers just use

type SInt = i32;
type UInt = u32;

It's not like you are likely to need u8 or u16 out of the blue anyway, and if you need to swap between i32 and i64, it works good enough.

Again, that's not a modular approach, you can't change those typedefs in your dependencies (unless you vendor them), but I believe it's good enough to solve the practical end-user problems while the proper language features are slowly worked out.


Regarding From-conversions, while nothing technically says they need to be lossless (and I'm sure they aren't in plenty of important cases), it's not an unreasonable assumption for the end user. If we add a lossy f32 -> u32 or u64 -> f32 conversion, I'm sure there will be confused users who got a bug because they lost precision. That's not good.

My position is that From-conversions are for situations where there is a single obvious right way to do it. If there are multiple ways to do a conversion, or if it has some caveats (because it may me fallible, or lossy, or just have a nasty edge case), or if the conversion is unexpectedly expensive, or there is even a slight possibility that the conversion would work differently in the future (require extra parameters, or extra runtime preconditions, or return a different type), then the operation shouldn't be a From impl. Add it as a method, or as a separate trait. Thank me later.

The core use of traits is generic code, and generic code just can't work with subtle type-specific constraints, by definition. The end user will pass in a T: From<U> without knowing the subtle edge cases of that impl, because there just isn't a proper place to document those subtleties, nor a reason to expect them to exist in the first place. The generic function will hit a nasty bug or performance degradation, because it has no way to guard against those edge cases at all. And even if you know that your From impl has some edge cases, the issue can be triggered deep in the generic call stack, making it impossible for the end user to expect or debug such issues.

Thus I think impl From<usize> for f32 is a bad idea, even though nothing technically prevents that impl. Lossy conversion traits may be fine, but that won't do much to solve the issue of ergonomics, and the "lossy" conversion may be lossy for different reasons, which may be better modeled with different traits anyway (e.g. for integers, truncation i32 -> i8 and sign-cast i32 -> u32 are both potentially problematic, but for different reasons).


Note that if you're fine with the semantics of as-casts and just want to have some genericity, you can easily define a CastAs<T> trait wrapping that operation. Implementation of it requires some boilerplate, but num-traits already did that work for you with their NumCast trait. It seems to have the semantics that you want, allowing int-to-float lossy casts, but disallowing narrowing or sign-discarding casts between integers. I recall seeing the simpler as-cast trait in the wild as well (maybe in some older version of num-traits).


Regarding the generic literals, personally I don't get the appeal. It's not hard to use explicit T::from conversions, and you'd need to put extra trait bounds on the generic function anyway, either T: From<Float> or T: ParseFloat. I get that having to write and read T::from(1.0) is worse than just 1.0, but this can mostly be solved with a local definition:

fn foo<T: From<Float>>(..) {
    let f = T::from;
    let x = f(1.0) * bar + f(.35);
    // etc
}

That's just marginally longer than a plain literal, so personally I don't find it a burden to use. You can cut out even more of those conversions if you put a bound like T: Mul<Float, Output=T> on your function, allowing to write bar * 1.0 instead of bar * f(1.0). A symmetric bound Float: Mul<T, Output=T> allows you to write 1.0 * bar, although this can be problematic to use in more generic code due to trait coherence issues.

And again, all of those issues are gone if you use a specific type instead of generic functions.


Overall, I believe that numeric code isn't as bad to write as you imply, moreso if you don't try to go fully generic. On the other hand, one can't expect the end users to know all of those niche tricks, and in generic library-level code the complexity becomes hard to work around. I think this really needs language-level support. I just don't believe it can be patched around by a couple of new traits, since the fundamental ergonomics and performance issues would remain.

9 Likes

The genericity of size problem can be partially solved within the existing traits system. In https://github.com/linalg-rs/rlst we are building a linear algebra library that abstracts around stack allocated or heap allocated data containers and supports n-dimensional tensors.

For example, to instantiate a 3-tensor with dimensions (4, 5, 6) on the stack you can say:

let arr = rlst_static_array!(f64, 4, 5, 6)

or to instantiate the same array on the heap you can say

let arr = rlst_dynamic_array!(f64, [4, 5, 6])

Both arrays have the same functionality and are indistinguishable in use. The numbers of dimensions (here 3) is hard-coded in the type as const expression. However, there are constructs to take lower-dimensional slices (e.g. take an n-1 dimensional slice of a n-dimensional array, which then has n-1 as type parameter) or promote a type to higher dimensions (e.g. interpret a one-dimensional vector as a two-dimensional matrix with just one column). All this works within the current type system, though is a bit cumbersome to implement.

The biggest problem is arithmetic with const expression, e.g. the statement

let arr = rlst_static_array!(f64, 4, 5, 6)

is a proc-macro that internally multiplies 4 x 5 x 6 and then instantiates an array with 120 elements on the stack. But it is not possible to write e.g.

let arr = rlslt_static_array!(f64, DIM1, DIM2)

where DIM1 and DIM2 are const expression parameters. This would only work with generic const expressions from nightly.

2 Likes

Note that I (somewhat) recently added a bunch of new docs about this, with libs-api signoff: https://doc.rust-lang.org/std/convert/trait.From.html#when-to-implement-from

We came up with 4 ways in which From is typically restricted, and that future implementations should also follow.

Notably, even when there's a single obvious way to convert between the types, it still might not be a good idea to have From implemented.

10 Likes

Your the problem you faced in your library is yet an other example :+1:

This issue with generic constant expressions was exactly what I meant in my initial post: quite often we need to concatenate/multiply dimensions of matrices and vectors, but the lack of generic constant expresssion makes it impossible for statically allocated matrices

2 Likes

My personal pet peeve is the design of arithmetic operation traits (Add, Mul, MulAssign etc), which works horribly with generic code, since you need to implement basically all combinations of val OP val, &val OP val, val OP &val and &val OP &val to have reasonable expression writing ergonomics

This is a pain I agree, I personnaly opt for a different way: I implement almost only Op<&T> for &T even for small types, and let the compiler optimize the by-ref code into by-value code when it is appropriate. This leaves me with a lot of explicit-referencing in my expression (which is also a pain) but at least it is DRY.

As a side-topic I sometimes consider it would be good to have implicit-referencing in rust, so we would not be obliged to write & everywhere in our code ... referencing or not could systematically be guessed by the compiler according to the called functions signatures. Anyway ...


I get that using eigen is simpler, but C?

I do not agree that one could do in rust anything you could in C, I meant not that easily. In C you can

  • easily cast between numbers (sometimes too easily ...)
  • overload functions (not fully replacing traits, but helping in dealing with non-generic code)
  • write macros implementing functions overloads for specific array sizes
  • use litterals in macro-generated code, because the litteral type is only infered after macro-expansion

For instance you can write the following combining these advantages:

#define define_vec(T, N)  \
  typedef struct {     \
    T array[N],        \
  } vec ## N        \
  size_t size(vec ## N * a)  { return N; }
  vec#N add(vec ## N * a, vec ## N * b) { ...}

define_vec(float, 3);
define_vec(float, 4);
vec3 a;
vec4 b;
vec3 c = add(&a, &a);
vec4 d = add(&b, &b);

This not perfect C code but you got the idea. Not as simple and readable as Eigen, but definitely something.


In fact, for scientific prototyping I wonder how many of your problems are self-inflicted

...

It's not like you are likely to need u8 or u16 out of the blue anyway, and if you need to swap between i32 and i64, it works good enough.

For prototyping, all what you said after is true: one do not really need generics for prototyping and instead can statically set the number types and even sizes. This is true for prototyping but not for writing libraries or functions for a large project.

The problem is: often I need u8 as much as u64

Let say I want to write a blur function like in openCV. This function is intended to operate on images which pixels have an arbitrary precision. So I need number traits or I have to implement it many times for specific elemnt types, or to use dynamic element typing like in openCV implementation. I cannot use macros for this in rust because as function overloading is not possible, I could not combine the specialized functions names in other macros. Obviously fixing UInt = u32 is not satisfying because in order to write efficient code (image manipulation here) I need to use the appropriate element type. And it is not the same for every operations along the program.


About NumCast, I do use it. My concern only is the same as everything in num-traits:

  • it is not a standard and thus the community and crates are fragmented about using it or an other competing solution
  • it is a trait providing methods that are redundants with the methods from core, hence it is convenient to use only with objects of a generic type. not with objects of concrete type (or you need to use the verbose turbofish operator ...). As mentioned in my initial post, this is leading to writing code with a certain set of functions when it is concrete types and a completely different set of functions when it is generic.
1 Like

If I had a choice of one feature to move from nightly to stable it would be generic const expressions. I would love to see progress on this.

Not that way[1], you can't. In C++ pretending to be C you can, but that's not C. Even with -std:gnu17 (GNU extensions to C17) you need __attribute__((overloadable)).

This works the same in Rust. In fact you can write the exact same thing you've written for pseudo-C but for Rust: [playground]

macro_rules! define_vec {($T:ident, $N:literal) => {
    paste! {
        pub struct [<vec$N>] {
            array: [$T; $N],
        }
        
        impl [<vec$N>] {
            fn size(&self) -> usize { $N }
            fn add(&self, other: &Self) -> Self { todo!() }
        }
    }
}}

define_vec!(f32, 3);
define_vec!(f32, 4);

let a: MaybeUninit<vec3> = MaybeUninit::uninit();
let b: MaybeUninit<vec4> = MaybeUninit::uninit();
let c = a.assume_init_ref().add(a.assume_init_ref());
let d = b.assume_init_ref().add(b.assume_init_ref());

or without the blatant UB: [playground]

macro_rules! define_vec {($T:ident, $N:literal) => {
    paste! {
        #[derive(Default)]
        pub struct [<vec$N>] {
            array: [$T; $N],
        }
        
        impl [<vec$N>] {
            fn size(&self) -> usize { $N }
            fn add(&self, other: &Self) -> Self { todo!() }
        }
    }
}}

define_vec!(f32, 3);
define_vec!(f32, 4);

let a = vec3::default();
let b = vec4::default();
let c = a.add(&a);
let d = b.add(&b);

Trait functions are the way to do function overloading in Rust, it's just a bit more syntactically heavy than ad-hoc overloading. Yes, core doesn't provide a way to concatenate identifiers together (and barely does so on nightly, concat_idents! only works for naming existing names), but proc macros do, and the paste crate is effectively standard in all but being available without a cargo dependency declaration. But you don't even need to do this, either: all you need is modules which provide the same API shape, and you can do $module::name to select the correct name.


But I do actually agree both that autoref for operators would've been preferable in retrospect to expecting Copy types to provide all of T $op T, &T op T, T op &T, and &T op &T; and that accepting T where &T is expected (semantics: moves the value into a temporary which is then referenced, i.e. &{$expr}) would be an interesting bit of ergonomics (although it could lead to people towards cloning where referencing would be sufficient, such as impl AsRef<T> parameters can do today.)


  1. You can use _Generic to make type-generic macros, but this requires knowing the set of supported type names when you define the macro. ↩︎

5 Likes

Random idea: If you can implement something for type UInt = u32; // or u64 with the same code, then you could (unidiomatically) do something like

mod u32 {
    type UInt = u32;
    include!("same_code.rs");
}
mod u64 {
    type UInt = u64;
    include!("same_code.rs");
}

The tooling might not play along perfectly, but this feels preferable to writing all the impls as a macro_rules macro. I'll have to try the next time I need to just duplicate code.

Not that way, you can't

:thinking: true, in stndard C I need _Generic ...

I didn't knew paste ! This is nice stuff thanks. So yes one can do the same in rust.

or without the blatant UB

It was only for the concept :wink:

I did it once, for writing tests in several modules which had syntactically mostly the same public-facing API. Not good, not terrible. For dozens of mostly-same simple test functions, certainly better than writing macros. But the IDE was confused about the module inclusion, basically assuming that the submodule is included in a single place. The pattern itself is also a bit obscure. The solution worked, but I would think twice about using it for any complicated code, and I certainly wouldn't want to use it for anything public-facing.

My currently preferred solution for dealing with this kind of repetitive code is a combination of crates duplicate and macro_rules_attribute. duplicate allows easy duplication of code for impls and simple functions over integers, while macro_rules_attribute allows deduplicating the duplicate attributes themselves. Neither crate depends on syn (at least with default features turned off), which means they add basically no compile time cost even for simple crates.

That doesn't work if similar snippets of code must be placed in different modules, though.

1 Like

For some prior art on numeric genericness, the Julia language is an interesting place to look. The conversion and type promotion story is really nice and makes math “just work” for the vast majority of cases. Every pair of numeric types has a defined common type in which both are pretty much representable. (Although Rust would probably want to be stricter here — Julia has e.g., 255u8 + 127i8 == 126u8, which is not great.)

Numeric literals actually seems like it shouldn't be too hard. Rust already does some inference on integer literals; doing similar inference on floating point should be possible and non-breaking? I know that Swift has traits for all kinds of expressible-by-literal, such as ExpressibleByFloatLiteral.

Hi folks, I am new here :wave:

I was curious about Rust and started working on a space mission design library last year which we are now using as the backend for an ESA-funded Python library.

This discussion is very interesting to me because I ran into all the issues that @jimy-byerley mentioned in the past few weeks.

I also agree with @bert that Julia would be a great source of inspiration for generic numerics. I've used it since 0.1 and only jumped ship because I missed ADTs and pattern matching after some excursions into functional programming and Julia lacks AOT compilation which makes distribution and TDD painful.

4 Likes

Rust does the same kind of inference on floats already: 1.0 will be an f32 or an f64 depending on how you use it.

Yes, but integer and float inference both only work with concrete types. You can't use literals at all for generic types.