Towards fearless SIMD
raphlinus.github.io
raphlinus.github.io
I can't help but think that SIMD intrinsics are the wrong level of abstraction. Literally assembly being moved into a high level language but without the ability for the compiler to optimize well. Hand written intrinsics can really be fine tuned for a given microarchitecture, but I think XLA's approach of having a higher level of abstraction, then JIT compiling to the device in question (whether it be SIMD of X width, or a gpu, or a tpu) is the right way to go.
Whenever I see hand coded assembly, I can't help but think to myself:
* Did the author actually have instruction scheduling information on hand when writing this?
* For what generation of chips was this found to be optimal for, and is it still? Invariably, you wind up with hand coded assembly routines that were written a long time ago still in use without anyone revisiting the code or it's performance.
I hope they will fix the compilers to add such guarantees. Currently, every time compilers try to mess with manually-written intrinsics, they decrease performance.
See this bug about LLVM failing to emit the exact instructions, decreasing the performance: https://bugs.llvm.org/show_bug.cgi?id=26491
See this bug about VC++ failing to emit them in the given order, again decreasing performance: https://developercommunity.visualstudio.com/content/problem/...
If you consider SIMD code that spans more than just one small function, then it is quite useful if the compiler can SROA (e.g. you pass around an array of vectors instead of having 8 arguments to each function, but still want them to end up in registers eventually), LICM (you're calling a function that needs a constant vector in a loop) or otherwise optimize your code.
If you want the compiler to leave alone your intrinsics, link in an assembly file.
__attribute__((always_inline)) / __forceinline usually help.
> LICM (you're calling a function that needs a constant vector in a loop)
I can calculate that vector outside of the loop.
> link in an assembly file.
Way more complex, I need to write outer scalar code in assembly too, need to manually allocate registers, also C++ templates are sometimes very useful for that kind of code.
In 99% of cases intrinsics are good enough for me, but I would love the compilers to leave alone my intrinsics.
I usually want to optimize the scalar code outside of the manually-vectorized body of the loops. A function call to that external compilation unit will be slower than inlining I have when everything is in the same unit. However, it could be the call overhead is small enough, obviously need to profile.
Higher level abstractions are a good idea, but the problem is, are you able to exploit the capabilities that the chip exposes? For simple things like computing a scalar function elementwise over a vector, no problem, but a lot of the more interesting problems don't fit such simple templates.
I had a few takeaways:
- OpenCL on GPUs and CPUs have little to do with each other (in terms of performance characteristics) and if you tune well for one of them, the other one will suffer.
- Vectorization of work items doesn't really work well unless your kernel is so simple that normal compiler auto vectorization with a loop would have probably worked just as well if not better.
- Nvidia intentionally makes OpenCL a second class citizen vs. CUDA. I had nearly identical (simple) kernels running on both platforms and only the CUDA one managed to saturate memory throughput.
- The whole ecosystem is mostly more effort than it's worth. Portability between different OpenCL implementations is a gamble, some will even silently compute invalid results (I'm looking at you Intel...). I had kernel hangs with both Nvidia and AMD.
This is one of the things that bothers me the most about OpenCL. It attempts to offer this uniform abstraction over a generic compute accelerator, which can be CPU vector extensions, GPUs, or FPGAs, but these things are different enough that you have to develop for a specific type if you want reasonable performance. So you get none of the benefits of a accelerator specific abstraction while still writing accelerator specific code.
There is a real cost to a generic abstraction, and distinct languages/platforms would in my mind be better than different "dialects" of the same language/platform that pretend to be compatible but really aren't.
I like that CUDA is very clearly designed to run only on GPUs - it provides a clarity that OpenCL lacks.
The other points are spot on and I would add that debugging code in OpenCL is a bad experience.
Or for the lazy:
I don't know about tensor flow in particular but are little-known methods of running "general purpose" parallel programs on GPUs. Specifically, H. Dietz' MOG, "Mimd on GPU". It's a shame the project hasn't gotten more attention imo.
See: https://en.wikipedia.org/wiki/Flynn%27s_taxonomy for explanations of terms.
I agree there should be something higher level we could use but it doesn't exist yet, to my knowledge.
1. SIMD - manually deal with packed elements (like in SSE, MMX etc)
2. DSP - Maybe someone knows a better term for this, but treating each slice of the data array as an independent serial stream (shaders, OpenCL, CUDA)
3. MIMD - freeform vector/matrix operations that get compiled down to the first two categories (MATLAB, GNU Octave, Scilab)
I'm not sure if TensorFlow fits best in 2 or 3 but my gut feeling is that it's closest to 2. The problem with the lower level abstractions is that it's more work to format the data for the problem space.
So with MATLAB, everything is a vector and operations applied to each vector happen in parallel across all elements. Then if you need to, you can drop down to less efficient code and operate on elements of the vector manually with C-like code.
Unfortunately that becomes more difficult in shaders, because you can't just magically access a neighboring element. And getting general computation to work with SIMD is often infeasible because you have to rewrite your code and potentially alter the layout of the data in memory to achieve better performance.
I also agree that currently nothing really exists to give us general-purpose vector math akin to MATLAB within a language like C.
I have "evangelized" one person at least.
The thing about MOG is it's a demonstrated technique that Dietz has not yet developed into a fully releasable product. His latest comment says it's six months from release but lacks funding.
I think the problem might be that most "Mimd" programming is things like weather-simulations where access to a MIMD supercomputer is standard and price isn't that much of an issue.
That said, cheap Simd parallel computing jump-started today's deep learning advances and so cheap MIMD might do things no one anticipated.
Still, very niche. But thanks for the mention.
Edit: Note, MOG is specifically a GPU. Dietz did earlier work on other machines in the 90s but this is GPU specific (though a less flexible architecture than what Nvidia has evolved now).
I agree that somewhat with AVX2 and especially with AVX-512 there's probably less reason to write intrinsics if you have a vector language. But for now SSE2, SSSE3, and SSE4 are still the bread and butter for SIMD on x86, and there are some important instructions (Fabien Giesen goes into detail at [1]) that you really have to think about how to use effectively. For example (this is mentioned at the end), all horizontal adds on x86 are awful except for PSADBW, which is really limited, as Intel designed it in a fit of myopia to target only motion estimation in contemporary codecs, and it requires you to basically design your whole algorithm around it.
[1]: https://fgiesen.wordpress.com/2016/04/03/sse-mind-the-gap/
The real problem is that SIMD hardware sets tends to have lots of instructions that have mixing of horizontal lanes. The scope and performance of these mixing instructions varies greatly from implementation to implementation, and these kinds of instructions are hard for compilers to automatically pick up. It's the latter case that means you need the SIMD intrinsics to be exposed to use the hardware effectively.
Even using intrinsics is dicey sometimes - compilers spill registers more often than can make sense. I think the ideal situation would be if you could somehow hint at register scheduling without writing asm yourself, but compiler are still a long ways from that.
The real problem is that, in my experience, compilers universally do a poor job of auto-vectorization/parallelization unless the code pattern is blindingly obvious. All of the "higher level abstractions" I've seen are no better at finding vectorizable patterns than the compiler does -- which makes sense since they are both just software. It ends up being syntactic sugar. The amount of effort required to trick compilers into doing the right thing, plus the things they won't do at all, is such that the complexity is worse than just writing the intrinsic code directly. Writing a lot of intrinsic code directly far from optimal but fortunately those intrinsics are available in languages like C++ that can abstract much of that.
Compilers will not consistently generate competent auto-vectorized for the foreseeable future. I've seen very little forward progress in the decade I've been exposed to it. There are still some relatively simple non-vectorized code patterns that compilers currently have a difficult time detecting and doing optimized code generation for.
I think a lot of that comes from C/C++ language corners.
Have you seen XLA (https://www.tensorflow.org/performance/xla/) or Glow (https://facebook.ai/developers/tools/glow)? These are very high level abstractions called from C++ that can do a great job vectorizing high level operations for the given device.
Now, I think the problem is that it is tying optimizations to instructions rather than CPU architectures. When I ported code from an I7 to an I9 it seemed that instruction latency changed. Still, if you are only supporting limited computational targets any hooks at all help.
But why intrinsic rather than assembly? Because usually vector extensions in GCC work well enough, and when they don't intrinsics are easier for me (a man who rarely needs to write assembly and doesn't have the time to beat the compiler in 99.99% of cases).
And a minor point... I really wish GCC had a pragma which allowed me to specify a function should be optimized to multiple targets and then install the thunks automatically.
Btw, this was a nice presentation of the idea: https://blog.linuxplumbersconf.org/2016/ocw/system/presentat...
vector float add2(float base,vector float offs)
{
return base + 2*offs;
}
double sum(double* ar,size_t ln)
{
vector double x = (vector)0.0;
while(ln >= sizeof vector) /* == # of simd lanes */
{
x += *ar; /* maybe this needs some kind of cast? */
ln -= sizeof vector; /* == 1 on arches with no simd support */
}
/* this part's definitely not done properly */
double buf[sizeof vector]; *buf = x;
double ret = 0.0; /* slurp up the last few */
for(size_t i=0; i<ln ;i++) ret += buf[i] + ar[i];
return ret;
}
0: https://pharr.org/matt/blog/2018/04/18/ispc-origins.html2: https://pharr.org/matt/blog/2018/04/26/ispc-volta-more-on-pe...
https://github.com/jackmott/simdeez
full disclosure, that's mine.
I really need to revive my multithreaded, SIMD-ified Rust PNG decoder someday with something like this…
> Another crate, with considerable overlap in goals, is simdeez. This crate is designed to facilitate runtime detection, and writing the actual logic without duplication, but leaves the actual writing of architecture specific shims to the user, and still requires nontrivial unsafe code.
It’s C++, so while it’s outside of the Rust ecosystem, it’s still a workable solution I’ve used in a half dozen projects.
I can't find the reference right now, but there have been some attempts to augment C++ compilers to understand the semantics of the library directly, basically treating the template metaprogramming as a DSL instead of general-purpose. This can be speed up the compile times dramatically, but of course you lose generality and the compiler has to be customized to understand every library it wants to optimize. Overall it seems like there is more research to be done on doing this in a safe way without paying through the nose with compile time.
Because that is what the original article is aiming for.
I think there are use cases for a fat binary, but I’m less certain as to its necessity.
that is what the Rust libs are doing, just using traits or macros instead of templates.
JITs could solve that problem, but few JITs currently do very much auto vectorization, because they don't have time.
Does LLVM not support GCC-style function multiversioning?
Edit adding citation: http://lists.llvm.org/pipermail/llvm-announce/2018-September...
ART started supporting SIMD on Oreo, and it was further expanded on Pie. Naturally very few devices have gotten those improvements thanks the state of Android's updates.
So on Android's case Renderscript is still the best way for a JIT like approach for SIMD.
ART is a good point, compile on install lets you do this.
http://cr.openjdk.java.net/~vlivanov/talks/2017_Vectorizatio...
https://software.intel.com/sites/default/files/managed/19/ae...
Also the Vector API development is ongoing and there is a talk at this week's Oracle ONE about the current state.
ART no longer compiles on install since Android 7, that behaviour is specific to Android 5 and 6 versions.
Since 7 it is a multistage runtime with hand written in Assembly interpreter, JIT + PGO, AOT + PGO on idle device. And as of 9, PGO data gets uploaded into the store and shared across devices on installation.
This makes sense when you think about it. The compiler would either need to be extremely capable of generalizing about some kinds of arithmetic, or it would need millions of special cases to recognize. By writing your own vectorization, you are basically covering your singular special case on your own.
Or about half the time it takes a photon from your phone screen to hit your eyeball
inline function sin9_shaper(x) c0 = 6.28308759 c1 = -41.33318707 c2 = 81.39900205 c3 = -74.66884436 c4 = 33.15324345
a = abs(x - round(x)) - 0.25
a2 = a * a
((((a2 * c4 + c3) * a2 + c2) * a2 + c1) * a2 + c0) * a
endfunction gen_sinwave(freq, init=0.0, step=0.1) wave = [sin9_shaper(x) for x = init:step:freq] end
julia> @code_native gen_sinwave(1113.0); .section __TEXT,__text,regular,pure_instructions ; Function gen_sinwave { ; Location: REPL[60]:2 pushl %ebx decl %eax subl $48, %esp vmovaps %xmm0, %xmm2 ; Function gen_sinwave; { ; Location: REPL[60]:2 decl %eax movl $769501344, %eax ## imm = 0x2DDDA8A0 addl %eax, (%eax) addb %al, (%eax) decl %eax movl $773805120, %ecx ## imm = 0x2E1F5440 addl %eax, (%eax) addb %al, (%eax) vmovsd (%ecx), %xmm1 ## xmm1 = mem[0],zero decl %eax movl %esp, %ebx vxorps %xmm0, %xmm0, %xmm0 decl %eax movl %ebx, %edi calll %eax decl %eax movl $769544608, %eax ## imm = 0x2DDE51A0 addl %eax, (%eax) addb %al, (%eax) decl %eax movl %ebx, %edi calll %eax ;} decl %eax addl $48, %esp popl %ebx retl nopw %cs:(%eax,%eax) ;}
end # module
What you wrote does not guarantee vectorization, it just relies on autovectorization.
Rust already does autovectorization magically behind the scenes thanks to LLVM (which Julia also uses), but explicit SIMD makes it a guarantee.
gen_sinwave(113.0)
calls gen_sinwave(1113.0, 0.0, 0.1)
(which are the default arguments). It is usually better to use @code_llvm because the LLVM IR is typically easier to read. julia> @code_llvm gen_sinwave(1113.0);
; Function gen_sinwave
; Location: REPL[2]:2
define nonnull %jl_value_t addrspace(10)*
@julia_gen_sinwave_345638059(double) {
top:
%1 = call nonnull %jl_value_t addrspace(10)* @julia_gen_sinwave_345638060(double %0, double 0.000000e+00, double 1.000000e-01)
ret %jl_value_t addrspace(10)* %1
}
However, defining function gen_sinwave(freq, init=0.0, step=0.1)
data = collect(init:step:freq)
fin9_shaper.(data)
end
and looking at @code_llvm gen_sinwave(1113.0, 0.0, 1.0)
we can see that there is a lot of auto-vectorization going onThere are some other ideas for making it easier to reason about safety at this level: https://github.com/rust-lang/rfcs/pull/2212
Can you point to where you heard about unsupported SIMD causing a panic? I'd like to fix that because it's really wrong!
1. A multibyte NOP might be used. Supposedly there might be a multibyte NOP that older CPUs will decode as a jump. I am not sure I believe this. Is there an example?
2. int3 might happen, causing SIGTRAP. I see no explanation of how this would occur.
So I think that, if LLVM really has UB if the wrong target is used, it should be fixed. Arguable the old Knights Landing instructions are an exception, but those are basically dead. Maybe non-x86 targets are different.
Also, I have a suggestion for a potentially much nicer way to deal with safety: use the type system instead of magic annotations. Have a function like GetAVX2() -> Option<AVX2>. Teach Rust that code that statically has a live AVX2 object (as a parameter, say) can use AVX2. Other than the code generation, this could be done in stable rust right now.
Also: the linked crate does use the type system in pretty much this way so that code that clients can be safe. However, there are limitations; it's not just whether a particular instruction can be used, which remains immutable once it's detected, but also which _registers_ (and, by extension, calling convention) can be used. That varies from function to function, and requires the `#[target_feature(enable)]` annotation to control, so just having an `Avx` type in hand is not quite enough to ensure that you're in a context where using the ymm registers is ok, and the intrinsic will be inlined to a "V" variant asm instruction. This is discussed in some detail in the "caveats" section.
Your type system idea works great for simple cases. It was the very first thing I did in my own SIMD code.
https://en.wikipedia.org/wiki/SIMD
ELI5: Your processor can process more data in parallel.
If you have for example loop that has something like this in the body:
a[i] + b[i] = c[i]
Using SIMD processor can make all this operations same time:
a[i] + b[i] = c[i]
a[i+1] + b[i+1] = c[i+1]
a[i+2] + b[i+2] = c[i+2]
a[i+3] + b[i+3] = c[i+3]
Normally in one iteration you would only get one of this addition done without SIMD, thanks to SIMD you can have multiple.
ETA: My bad - I did not think to click that. Embarrassing. Thanks for pointing it out!
Style manuals of the time encouraged expanding acronyms on first use, but I can imagine this wasn't consistently done in a technical field!
What are the use cases for having multiple alternates in the same binary? Why not decide them during the compile time for the given architecture?
If portability is the concern here, wouldn't it lead to sub-optimal code anyway?
1. A portable binary where only individual SIMD operations are optimized for all targets. 2. Building the optimized binary for every target architecture when needed (either by the user or by the binary distributor).
Concern with (1) is, as the number of dynamically called functions (or decided by if-else nests) increases the quality of the generated code reduces for any architecture. Basically, compiler will be left with opaque unrecognizable functions which restricts even the target independent optimizations (Like, GVN, CSE Constant propagation etc).
Let's say, if the user writes a SIMD program which contains full of dynamically called functions (which are opaque to the compiler), doesn't it impact the performance heavily?
Isn't taking the compiler support for optimizing the SIMD operations necessary rather than writing wrapper libraries ? For example, lowering the SIMD operation calls to the existing vectorized math libraries which are recognizable by the compilers ( Example: sin(), cos(), pow() in libm ).