Introduce SIMD implementation for x86_64 - #38
Conversation
No bindings yet, both SSE and AVX variants were roughly tested manually.
Also make symbols to vectorized functions default to nullptr whenever no suitable implementation is found.
|
cc @kozross I'll be tweaking the benchmark code to reflect the scalar & SIMD implementations but this looks promising so far. |
This reverts commit 67cf73f.
I think I'm going to deactivate CI for 32-bit, this is becoming ridiculous. |
|
Looking at the benchmarks, it would appear that we seem to pay around 100ns overhead for using FFI, which can be observed for 25 elements: We break even at 100 elements, and after that we are undeniably faster: Similar behaviour is observed for |
|
Drive-by comment: IIUC
|
| cbits/base16.c | ||
| cbits/avx_base16.c | ||
| cbits/sse_base16.c | ||
| cc-options: -O3 -std=c2x |
There was a problem hiding this comment.
-O3? Why? It's more aggressive and unsafe than O2. Is there evidence that there's actually a performance gain? If so, I'd rather figure out which optimization flag exactly causes performance improvements over O2.
There was a problem hiding this comment.
Here, -O3 only applies to the vector implementations in cbits. They're simple processing loops without memory management. So the drawbacks of -O3 (aggressive inlining making profiling/debugging difficult, memory allocations being skipped if the optimizer thinks it's ok, code size growth due to unrolling...) are not very important IMO and -O3 might squeeze some more performance due to unrolling.
Though to be fair, -O2 probably would not make much of a difference since we're heavily guiding the compiler here anyway.
|
Benchmarks with |
I'd strongly advise against it. Edit: if it's just our own cbit functions, then I guess it's alright. |
The first vector iteration is made to overlap with the second one, in such a way that from the second interation onwards, the input pointer is suitably aligned.
There was a problem hiding this comment.
I really appreciate these contributions @Granahir2 and @Kleidukos! Just add tests that explicitly exercise the new codepaths and I'm happy.
| #include "base16.h" | ||
|
|
||
|
|
||
| __attribute__((target("avx2"))) |
There was a problem hiding this comment.
glad we're starting here and not avx512 - thanks.
| bool isSIMDAvailable() { | ||
|
|
||
| #ifdef __x86_64__ | ||
| if(__builtin_cpu_supports("sse4.1")) { | ||
| return true; | ||
| } | ||
| #endif | ||
|
|
||
| return false; | ||
| } |
There was a problem hiding this comment.
Does this function need to exist? it will not inline nicely in the Base16.hs calls and creates a branch at the call site. I'd rather see this at the callsite as a CPP directive than calling it direclty like this and branching on it. GCC and Clang support __SSE4_1__ directives and associated intrinsics.
There was a problem hiding this comment.
@emilypi For clarification, do you mean that the Haskell code should check whether or not SIMD is available through CPP? We felt that a run-time check would increase the portability of distributed binaries instead, instead of having to distribute cpu-specific bindists.
There was a problem hiding this comment.
instead of
encodeBase16' :: ByteString -> Base16 ByteString
encodeBase16' =
if c_isSIMDAvailable
then encodeBase16SIMD
else assertBase16 . encodeBase16_
{-# INLINE encodeBase16' #-}why not write
encodeBase16' :: ByteString -> Base16 ByteString
encodeBase16' =
#ifdef __SSE4_1__
encodeBase16SIMD
#else
assertBase16 . encodeBase16_
#endif
{-# INLINE encodeBase16' #-}I know it's brittle, but the call isn't so interesting that i'd want to kick that to a ccall. You're already making a CPU-specific branch. The question is whether to determine that branch at compile time vs runtime. I don't see why i'd want to pay the runtime cost.
There was a problem hiding this comment.
The disadvantage of this approach is that a binary compiled on a machine that doesn't have SSE4.1 won't ever execute the SIMD version, even if it runs on a machine that does have SIMD support.
With isSIMDAvailable, we sidestep this and can use one binary per architecture. Currently, the specific encodeBase16SIMD variant is picked at object load time (and the symbol is NULL if no variant is available). Note that __builtin_cpu_supports results are cached (according to GCC docs) so isSIMDAvailable is not probing the CPU at every call.
Another way to do it would be to package a scalar C version of the code as a fallback, then every call could go through encodeBase16SIMD (which would have to be renamed) and the branch can be eliminated. That way there's virtually no runtime dispatch cost.
I don't much Haskell; would there be a way to keep the branch, but cache its result somehow, knowing that it'll always resolve to the same value? Or maybe do something similar to the C side, where the specific implementation of encodeBase16' is chosen at load time? I guess it also comes down to how important portability should be here.
There was a problem hiding this comment.
Note that __builtin_cpu_supports results are cached (according to GCC docs) so isSIMDAvailable is not probing the CPU at every call.
Noted, and I do agree that the ccall is a more general approach. My line of questioning here is more to figure out what kind of operation we need to focus on, because yes, while a more general approach would be good for more architectures, if the general shape of programs that we're looking at are largely 1-off hex encodings, it just adds extra unnecessary overhead. However, for programs that expect to hex encode a lot of things, successively, or along running program that expects to encode or decode things over a long time span, it would make sense. We also need to consider the size of inputs in all cases.
The benchmarks are a little bit of a lie due to the way GHC optimizes, and because we don't really know what the distribution of values is that are being hex encoded. I fuck them up with randomness for this reason, but also, we run the gamut from 24chars up through 1mb ish worth of chars, but realistically, I haven't seen anything in the wild >100 chars that needs to be hexed, so the utility is mixed to me (unlike, e.g., SIMD on base64, which is routinely used to encode gigabytes). On average, GHC will optimize for a hotpath if there are a lot of successive calls to a particular branch within some time span due to the way it applies its heuristics (see: -fcmm-static-pred), so it will "cache" informally. So that leaves us wtih two options really, for how we should model the user: long running programs vs. one-offs, given some distribution of inputs of different sizes. I think we can assume correct values in most cases for encodeBase16 and decodeBase16.
Do we have any indication of how the library is going to be used? Maybe this is a quesion for @Kleidukos - what's your take?
Another way to do it would be to package a scalar C version of the code as a fallback, then every call could go through encodeBase16SIMD (which would have to be renamed) and the branch can be eliminated. That way there's virtually no runtime dispatch cost.
This is how i've seen it done by others (e.g. Dan Lemire's simdjson, A Klomp's base64). It looks tedious to maintain tho. If anyone has a strong opinion I'll fold - i just want this in everyone's heads. If it's reasonable to run SIMD in every case and drop the generic support within some time frame, I'm on board with that too.
There was a problem hiding this comment.
Do we have any indication of how the library is going to be used? Maybe this is a quesion for @Kleidukos - what's your take?
I can put out a call to users of base16 so that they can tell us a bit about their usages. And I can also check the list of users here: https://flora.pm/packages/@hackage/base16/dependents?page=1
There was a problem hiding this comment.
Geez alot more people are using than the last time I checked in 😅
There was a problem hiding this comment.
@emilypi here's my conclusion: The SIMD code paths should be guarded by cabal flags so that the vast majority of current users are not impacted by the overhead. However we still give end-users of the library control over the code path so that they may adapt the strategy to their workloads. How does that sound to you?
| isValidBase16 (BS ptr len) = | ||
| accursedUnutterablePerformIO $ do | ||
| isValidBase16 bs@(BS ptr len) = | ||
| #if !defined(PURE_HASKELL) && defined(SIMD) |
There was a problem hiding this comment.
I believe in the text example you are copying, SIMD was pre-existing, and chose between different implementations inside the cbits. PURE_HASKELL removed the cbits altogether. The flags were making semantically different (although overlapping) choices.
In this patch, I don't see any case where the semantics are different -- they're just opposite sign of each other. SIMD == !PURE_HASKELL. Either you're using pure haskell, or you're using simd instructions. Maybe one flag suffices?
There was a problem hiding this comment.
Here's my reasoning: I tried to abide by existing flags in the ecosystem (simd and pure-haskell) and they will also mean different things at some point in the future:
-
simdwill mean using generated code that uses SIMD instructions, be it- Through C (like today) or
- Through native support for SIMD (See the meta-issue)
-
pure-haskellmeans no using C dependencies, but not necessarily no SIMD (especially for Wasm).
Typically, WASM will have SIMD but Pure Haskell, whereas JavaScript will have No SIMD and Pure Haskell.
Now I understand that this makes this tri-state logic harder to reason about. I'm just trying to find a future-proof model for those flags so that they don't have to be reworked later on.
Maybe those are bad flags (not granular enough) and we could have:
c-simd: SIMD is done through FFI to Cghc-simdSIMD is done through GHC primopspure-haskellNo C, compatible withghc-simdbut notc-simd
What do you think @chreekat?
There was a problem hiding this comment.
Ah! Well, I don't understand the usage patterns well enough to have a useful opinion. I don't know the trade-offs of using simd, either. Having fewer knobs and good defaults would be the principles I would use to decide in this case. Which would probably result in the patch as-is.
There was a problem hiding this comment.
In our specific case of base16, the "good default" is not to enable it, because the majority of users have small data to encode/decode. However we want to leave the possibility to enable SIMD to (transitive) users of this library when they know they have big data sets to process.
Co-authored-by: Bryan Richter <b@chreekat.net>
This PR adds a SIMD implementation very generously gifted by @Granahir2 for x86_64.
The
base16.cinterface provides a way to easily dispatch between scalar (Haskell), SSE 4.1 and AVX2.HTML report is attached, feast your eyes:
base16.html