FR | EN
Quentin L. Meunier
Associate Professor in Computer Science at Sorbonne Université

Finding an efficient shuffle in SIMD

The SIMD instructions are extensions to standard instruction sets, which apply the same operation on registers containing several words. It is a way to make programs parallel at a low-level. Among SIMD instructions, some are used to shift the words inside a register, or to mix words coming from two different registers. These instructions are called "shuffle".
Among the uses of the shuffle, a pattern often used in SIMD consists in taking the left-most word of a register, and to concatenate it with the all the words (except the left-most one) of another register, as shown on the figure below.


A colleague who is a specialist of SIMD has challenged me to find the minimum number of instructions to achieve this pattern with the AVX instruction set (specified at here). The approach I used is an enumerative approach.
I went through a certain number of steps before arriving to the last solution, which I think minimizes the computation time and memory usage. I will try to describe here the successive steps.
The basic idea is to represent a word by a unique value, since we do not consider arithmetical or logical operations. Thus, initially, we consider that the first 8-word register contains the values from 0 to 7, and the second register the values from 8 to 15. We can already note that there is a maximum of 168 = 232 possible values for the destination register. The data structure storing the reachable values appears naturally to be a tree, in which a given depth in the tree corresponds to the index of the word in the destination register. Each node contains an array of pointers towards the children nodes, indexed by the value of the child node. Thus, on 4-word registers, the resulting value [ 2, 0, 3, 2 ] is stored by a node in the tree reached by taking successively the pointers at the indexes 2, 0, 3 and 2 of the encountered nodes (see figure below).


Additionally, the program must store the way this result has been reached, what is done in the leaf nodes by keeping pointers towards the operand nodes, the used instruction and the potential immediate in the instruction.
The first algorithm I thought of is the following:

Current = [ src0, src1 ] (liste)
Processed = { } (set)
Results = { src0, src1 } (set)

while (!Current.empty()):
   c = Current.head()
   for each instruction [single op] and each immediate imm:
      g = inst(c, imm)
      if (g ∉ Results):
         Results.add(g)
   for each p in processed:
       for each instruction [double op] and each immediate imm:
         g = inst(c, p, imm)
         if (g ∉ Results):
            Results.add(g)
            Current.add(g) # add to tail
   Current.pop()
   Processed.add(c)

The problem of this algorithm is that the results are not stored according to their order, i.e. the number of instructions required to reach them. If the order is stored in a leaf (and computed from the parents order), it is not easy to update the nodes for which the current node intervenes as an operand. Indeed, during the course of the Processed set, the nodes are not traveled in ascending order, and it is possible to obtain the same result with a lower order later in time. The other problem is that it is more difficult to determine a stopping condition that guarantees an optimal result, that is to say a moment from which we are sure that we can no longer produce results of an order inferior to a given order.
Another idea is to store the values to be processed according to their order, in a list (there is therefore a list by order). A tree is kept to quickly test if a result has already been obtained, as well as store its construction. The goal is to browse the lists containing the values to be processed so as to produce all the results of a given order at one time. The algorithm becomes the following:

order[0] = [ src0, src1 ] (liste)
Results = { src0, src1 } (set)

target_order = 1

while (target_order < MAX_ORDER):
   for elem in order[target_order - 1]:
      for each instruction [single op] and each immediate imm:
         g = inst(c, imm)
         if (g ∉ Results):
            Results.add(g)
            order[target_order].add(g)
   for all combinations (i, j) with i < j such that i + j == target_order - 1:
      for each elem0 in order[i]:
         for each elem1 in order[j]:
            for each instruction [double op] and each immediate imm:
               g = inst(c, p, imm)
               if (g ∉ Results):
                  Results.add(g)
                  order[target_order].add(g) # add to tail
            if Results.contains(target):
               print target stack
   target_order += 1

Other optimization were successively made: The final code is here.