Skip to content

Add f64<->f32 widening/narrowing conversions - #127

Closed
valadaptive wants to merge 1 commit into
linebender:mainfrom
valadaptive:float-conversions
Closed

Add f64<->f32 widening/narrowing conversions#127
valadaptive wants to merge 1 commit into
linebender:mainfrom
valadaptive:float-conversions

Conversation

@valadaptive

Copy link
Copy Markdown
Contributor

These were requested in #simd > Quickly deinterleaving the underlying Simd arrays?, and seem useful.

They are a bit unusual in Neon, which has specific instructions for converting the low and high halves (and combining the two).

Due to some rearrangement and cleanup of the code generation, the reinterpret ops have been moved around a bit. They haven't been changed at all.

@Ralith Ralith left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any idea how to expose this to native-width code?

@valadaptive

Copy link
Copy Markdown
Contributor Author

Probably through adding SimdWiden and SimdNarrow traits similar to the existing SimdCvtTruncate and SimdCvtFloat traits.

@Ralith

Ralith commented Nov 16, 2025

Copy link
Copy Markdown
Collaborator

What would the return types be, though? We only have associated types of one width.

@valadaptive

valadaptive commented Nov 16, 2025

Copy link
Copy Markdown
Contributor Author

Hm, I see the issue. Maybe the Zulip thread's "split when widening / combine when narrowing" approach is better for this?

@Ralith

Ralith commented Nov 16, 2025

Copy link
Copy Markdown
Collaborator

I think that's probably the best we can do in current Rust, yeah. No reason not to leave that for follow-up work, of course.

@valadaptive

Copy link
Copy Markdown
Contributor Author

If the split/combine approach is what we're going with, it might be good to separate the conversions from the existing widen/narrow ops' convention. Like, let's say we want to implement the "generic" widen operation on f32x4. We call widen_f32x4, which widens to f64x4. Unless we're using AVX2, its implementation will split the f32x4 into two separate f64x2 vectors and widen them separately before combining them. Then, we call split on the resulting f64x4 that we just combined and return that. Sure, the compiler may elide the split/combine/split, but I wouldn't count on it.

@LaurenzV, would it be a good idea to just update the "widen/narrow" signatures to return and consume two vectors respectively? The Simd trait's operations cap out at a specific bit width of 512, not a specific scalar count. This means the current widen/narrow operations will never be implementable for all bit widths, since the highest-bit-width version would need to return a type wider than anything that exists.

@LaurenzV

Copy link
Copy Markdown
Collaborator

Seems reasonable, but maybe something for office hours.

Shnatsel added a commit to Shnatsel/fearless_simd that referenced this pull request Aug 7, 2026
We only had widen/narrow between u8 and u16 before. This PR removes that
and builds a generic widen/narrow from the ground up, using the API from
linebender#127. It covers all integer and float types.

Implementations for floats, and for integers on AVX-512, NEON and WASM
are a breeze. Very straightforward, love them.

SSE4.2 and AVX2 only have saturating conversions but not truncating ones
for integers, so truncating ones have to be emulated on top of
saturating ones. There are also no conversions to/from 64-bit integers,
so those have to be emulated too.

I included saturating conversions in addition to truncating ones in the
API, since all hardware has them natively, even older x86. This did end
up adding a bit of complexity because 64-bit saturating casts have to be
emulated on AVX2 and earlier. I think it's still worth keeping because
they don't add that much. All the other complexity (truncating
conversion emulation on AVX2 and earlier) is unavoidable. I've axed
SSE2-specific kernels since autovectorization works okay for them, and
I'm not willing to complicate this even further.

Since this is still quite a lot of complexity due to all the x86
emulation, I've added extensive tests on concrete values as well as a
random test under `#[ignore]` to be run in release mode used to validate
it, same as for other non-trivial ops like linebender#276

Performance-wise rust-lang/rust#159464 bites
us here (regression in Rust 1.96, 1.95 and earlier work fine) but it's
not so bad that scalar wins, so nothing we can do about it right now
(other than inline assembly but i'm not willing to go there). This is
[expected to be
fixed](rust-lang/rust#159464 (comment))
in the upgrade to LLVM 23 which is coming Soon™. In the meantime this PR
is tuned for 1.95 on llvm-mca.

## Generic API considerations

There's an API decision on how to expose these ops to generic code,
specifically the int/float distinction. It's a tension between
flexibility and brevity.

In this PR I implemented both regular and saturating for floats and they
just do the usual rounding, same as `as` casts. This abstracts over
vector type if all you want is widen/narrow. However, if you want to
operate on the widened vector afterwards, you need an additional generic
bound, e.g. `V::Widened: SimdInt<S>`:

<details><summary>Snippet with an additional bound</summary>
<p>

```rust
use fearless_simd::{SimdFrom, prelude::*, u8x16};

#[inline(always)]
fn add_widened_halves<S, V>(value: V) -> V::Widened
where
    S: Simd,
    V: SimdInt<S> + SimdWiden<S>,
    V::Widened: SimdInt<S>,
{
    let (low, high) = value.widen();
    low + high
}

#[inline(always)]
fn fixed_width<S: Simd>(simd: S) -> [u16; 8] {
    let input = u8x16::simd_from(
        simd,
        [1, 2, 3, 4, 5, 6, 7, 8, 10, 20, 30, 40, 50, 60, 70, 80],
    );

    add_widened_halves(input).into()
}

#[inline(always)]
fn native_width<S: Simd>(simd: S) -> u16 {
    let input = S::u8s::from_fn(simd, |i| i as u8 + 1);
    add_widened_halves(input)[0]
}
```

</p>
</details> 

This is not a problem on one function, but this extra bound is
infectious, so all callers have to require it too until a concrete type
is reached. So you have `SimdWiden<S> + V::Widened: SimdInt<S>`
propagated all the way up the call stack.

Alternatively, one could argue floats really don't have the saturating
variant and maybe they should only implement `narrow` but not
`saturating_narrow`. So we could make two traits, `SimdNarrowInt<S>` and
`SimdNarrowFloat<S>`. So instead of sometimes carrying the
`SimdNarrow<S> + V::Narrowed: SimdInt<S>` bound your entire call stack
you only carry `SimdNarrowInt<S>`, one having `saturating_*` and the
other not, at the expense of not being able to abstract over ints and
floats in widen/narrow ops anymore. Abstracting over ints and floats
isn't that useful right now since `SimdBase` doesn't have anything on
it, but this is something we can and probably should change now that
masks aren't `SimdBase`, see linebender#301.

Or we could have both `SimdWiden` and `SimdWidenInt`/`SimdWidenFloat`,
with the current behavior and `SimdIntWiden`/`SimdFloatWiden` being
shorthands to avoid carrying extra bounds - at the expense of more than
one trait providing `narrow` and `widen` for each op, and that being
potentially confusing.

Part of linebender#297

Supersedes linebender#127
@Shnatsel

Shnatsel commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Superseded by #300 which is now merged, closing.

@Shnatsel Shnatsel closed this Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants