Then partitioning is 'simply' a vector comparison, two masked compressing stores (through shuffles or _mm_mask_compressstoreu_epi32) with one of the masks inverted, and counting how many elements were smaller with _mm256_movemask_epi8 and a popcnt.
For an out-of-place partition you can interleave the loops of one going left-to-right and one going right-to-left to increase instruction-level parallelism.
You face some additional trouble when the right region of already-partitioned elements is smaller than 4. I think this is solvable but I don't have a good proposal off the top of my head.
Edit: well, one solution is to look at the first 8 elements. If at least 4 belong on the right, you can process these 8 carefully and then process the input forwards without worrying about your loads overlapping. If fewer than 4 belong on the right, you can swap these 8 with the last 8, process those 8 carefully, and process the input backwards without worrying about your loads overlapping.
All of this is what we have to do anyway when targeting AVX2, even if filtering some elements and discarding others or partitioning to 2 output buffers, because there are no compressing instructions.
If you have AVX512 available shuffle(xs, lut[movemask(compare_result)]) could also be two compressions with one reverse shuffle in between.
> That is, there is a single iterator scanning the array from left-to-right (jj). If the element is found to belong in the left partition, it is swapped with the first element of the right partition (tracked by ii), otherwise it is left where it is.
So you start left, the first element on the left partition belongs in the left partition, so you swap it into the right partition? And what about the element that was in the right partition, when do you check where that one belongs?
Yes and no. You temporarily do swap it into the right partition, but then increment the pointers which redefines where the right partition is: you swap it with the first element of the right partition before incrementing both i and j. In essence what this does is move the entire right partition one step to the right, while putting the previously unknown element before it.
Consider this example:
l l l l r r r r r ? ? ? ?
^ ^
i j
Now if the first ? was an r element, you could simply increment j and be done with it. But suppose it was an l element, then you have this scenario: l l l l r r r r r l ? ? ?
^ ^
i j
Note that "the first element of the right partition" is equivalent to "the first element after the left partition". Does it now make more sense that swapping the first element of the right partition and the unknown element (v[i], v[j]) is the right thing to do? After our swap we have this: +- swap --+
v v
l l l l l r r r r r ? ? ?
^ ^
i j
So now incrementing i and j both fixes our invariant: l l l l l r r r r r ? ? ?
^ ^
i j
> And what about the element that was in the right partition, when do you check where that one belongs?That one still belongs in the right partition, which is why we increment both i and j.
EDIT: I'm now on the desktop and see that your graphs clarify this much better, on mobile I had Dark Reader and they were invisible. You may want a white background.
You were thinking of Hoare partitioning. Quicksort can use either Hoare or Lomuto (or any other) partitioning algorithm.
You can look at the full assembly here: https://cpp.godbolt.org/z/zzzTh47PG. But the full assembly isn't a fair comparison right now because I did not bother to convert both functions to have the same signature (Andrey's version assumes the pivot is in the array and selects it in the function).
Virtually all the time for any non-trivial input is spent in the inner loop though.