SimdShape

Links:

Requirements Analysis

The dynamic partitioned SimdSignal class is based on the logical extension of the full capabilities to the nmigen language behavioural constructs to a parallel dimension, with zero changes in that behaviour as a result of that parallelism.

Logically therefore even the concept of ast.Shape should be extended solely to express and define the extent of the parallelism and SimdShape should in no way attempt to change the expected behaviour of the Shape class behaviour from which it derives.

A logical extension of the nmigen ast.Shape concept, SimdShape provides sufficient context to both define overrides for individual lengths on a per-mask basis as well as sufficient information to "upcast" back to a SimdSignal, in exactly the same way that c++ virtual base class upcasting works when RTTI (Run Time Type Information) works.

By deriving from ast.Shape both width and signed are provided already, leaving the SimdShape class with the responsibility to additionally define lengths for each mask basis. This is best illustrated with an example.

Also by fitting on top of existing nmigen concepts, and defining the SimdShape.width equal to and synonymous with Shape.width then downcasting becomes possible and practical. (An alternative proposal to redefine "width" to be in terms of the multiple options, i.e. context-dependent on the partition setting, is unworkable as it prevents downcasting to e.g. Signal)

The Libre-SOC IEEE754 ALUs need to be converted to SIMD Partitioning but without massive disruptive code-duplication or intrusive explicit coding as outlined in the worst of the techniques documented in dynamic simd. This in turn implies that Signals need to be declared for both mantissa and exponent that change width to non-power-of-two sizes depending on Partition Mask Context.

Mantissa:

  • when the context is 1xFP64 the mantissa is 54 bits (excluding guard rounding and sticky)
  • when the context is 2xFP32 there are two mantissas of 23 bits
  • when the context is 4xFP16 there are four mantissas of 10 bits
  • when the context is 4xBF16 there are four mantissas of 5 bits.

Exponent:

  • 1xFP64: 11 bits, one exponent
  • 2xFP32: 8 bits, two exponents
  • 4xFP16: 5 bits, four exponents
  • 4xBF16: 8 bits, four exponents

SimdShape needs this information in addition to the normal information (width, sign) in order to create the partitions that allow standard nmigen operations to transparently and naturally take place at all of these non-uniform widths, as if they were in fact scalar Signals at those widths.

A minor wrinkle which emerges from deep analysis is that the overall available width (Shape.width) does in fact need to be explicitly declared under some circumstances, and the sub-partitions to fit onto power-of-two boundaries, in order to allow straight wire-connections rather than allow the SimdSignal to be arbitrary-sized (compact). Although on shallow inspection this initially would seem to imply that it would result in large unused sub-partitions (padding partitions) these gates can in fact be eliminated with a "blanking" mask, created from static analysis of the SimdShape context.

Example:

  • all 32 and 16-bit values are actually to be truncated to 11 bit
  • all 8-bit values to 5-bit

from these we can write out the allocations, bearing in mind that in each partition the sub-signal must start on a power-2 boundary,

      |31|  |  |24|     16|15|  |   8|7     0 |
32bit |           |          |  | 1.11        |
16bit |     | 2.11        |  |  | 1.11        |
8bit  |  |  4.5   | 3.5   |  | 2.5   | | 1.5  |

Next we identify the start and end points, and note that "x" marks unused (padding) portions. We begin by marking the power-of-two boundaries (0-7 .. 24-31) and also including column guidelines to delineate the start and endpoints:

      |31|  |  |24|     16|15|  |   8|7     0 |
      |31|28|26|24| |20|16|15|12|10|8| |4   0 |
32bit | x| x| x|  |      x| x| x|10 ....    0 |
16bit | x| x|26    ... 16 | x| x|10 ....    0 |
8bit  | x|28 .. 24|  20.16| x|12 .. 8|x|4.. 0 |
unused  x                   x

thus, we deduce, we actually need breakpoints at nine positions, and that unused portions common to all cases can be deduced and marked "x" by looking at the columns above them. These 100% unused "x"s therefore define the "blanking" mask, and in these sub-portions it is unnecessary to allocate computational gates.

Also in order to save gates, in the example above there are only three cases (32 bit, 16 bit, 8 bit) therefore only three sets of logic are required to construct the larger overall computational result from the "smaller chunks". At first glance, with there being 9 actual partitions (28, 26, 24, 20, 16, 12, 10, 8, 4), it would appear that 29 (512!) cases were required, where in fact there are only three.

These facts also need to be communicated to both the SimdSignal as well as the submodules implementing its core functionality: add operation and other arithmetic behaviour, as well as cat and others.

In addition to that, there is a "convenience" that emerged from technical discussions as desirable to have, which is that it should be possible to perform rudimentary arithmetic operations on a SimdShape which preserves or adapts the Partition context, where the arithmetic operations occur on Shape.width.

>>> XLEN = SimdShape(fixed_width=64, signed=True, ...)
>>> x2 = XLEN // 2
>>> print(x2.width)
32
>>> print(x2.signed)
True

With this capability it becomes possible to use the Liskov Substitution Principle in dynamically compiling code that switches between scalar and SIMD transparently:

# scalar context
scalarctx = scl = object()
scl.XLEN = 64
scl.SigKls = Signal         # standard nmigen Signal
# SIMD context
simdctx = sdc = object()
sdc.XLEN = SimdShape({1x64, 2x32, 4x16, 8x8})
sdc.SigKls = SimdSignal     # advanced SIMD Signal
sdc.elwidth = Signal(2)

# select one 
if compiletime_switch == 'SIMD':
    ctx = simdctx
else:
    ctx = scalarctx

# exact same code switching context at compile time
m = Module():
with ctx:
    x = ctx.SigKls(ctx.XLEN)
    y = ctx.SigKls(ctx.XLEN // 2)
    ...
m.d.comb += x.eq(Const(3))

An interesting practical requirement transpires from attempting to use SimdSignal, that affects the way that SimdShape works. The register files are 64 bit, and are subdivided according to what wikipedia terms "SIMD Within A Register" (SWAR). Therefore, the SIMD ALUs have to both accept and output 64-bit signals at that explicit width, with subdivisions for 1x64, 2x32, 4x16 and 8x8 SIMD capability.

However when it comes to intermediary processing (partial computations) those intermediary Signals can and will be required to be a certain fixed width regardless and having nothing to do with the register file source or destination 64 bit fixed width.

The simplest example here would be a boolean (1 bit) Signal for Scalar (but an 8-bit quantity for SIMD):

m = Module():
with ctx:
    x = ctx.SigKls(ctx.XLEN)
    y = ctx.SigKls(ctx.XLEN)
    b = ctx.SigKls(1)
m.d.comb += b.eq(x == y)
with m.If(b):
    ....

This code is obvious for Scalar behaviour but for SIMD, because the elwidths are declared as 1x64, 2x32, 4x16, 8x8 then whilst the elements are 1 bit (in order to make a total of QTY 8 comparisons of 8 parallel SIMD 8-bit values), there correspondingly needs to be eight such element bits in order to store up to eight 8-bit comparisons. Exactly how that comparison works is described in eq

Another example would be a simple test of the first nibble of the data.

m = Module():
with ctx:
    x = ctx.SigKls(ctx.XLEN)
    y = ctx.SigKls(4)
m.d.comb += y.eq(x[0:3])
....

Here, we do not necessarily want to declare y to be 64-bit: we want only the first 4 bits of each element, after all, and when y is set to be QTY 8of 8-bit elements, then y will only need to store QTY 8of 4-bit quantities, i.e. only a maximum of 32 bits total.

If y was declared as 64 bit this would indicate that the actual elements were at least 8 bit long, and if that was then used as a shift input it might produce the wrong calculation because the actual shift amount was only supposed to be 4 bits.

Thus not one method of setting widths is required but two:

  • at the element level
  • at the width of the entire SIMD signal

With this background and context in mind the requirements can be determined

Requirements

SimdShape needs:

  • to derive from nmigen ast.Shape in order to provide the overall width and whether it is signed or unsigned. However the overall width is not necessarily hard-set but may be calculated
  • provides a means to specify the number of partitions in each of an arbitrarily-named set. for convenience and by convention from SVP64 this set is called "elwidths".
  • to support a range of sub-signal divisions (element widths) and for there to be an option to either set each element width explicitly or to allow each width to be computed from the overall width and the number of partitions.
  • to provide rudimentary arithmetic operator capability that automatically computes a new SimdShape, adjusting width and element widths accordingly.

Interfacing to SimdSignal requires an adapter that:

  • allows a switch-case set to be created
  • the switch statement is the elwidth parameter
  • the case statements are the PartitionPoints
  • identifies which partitions are "blank" (padding)

SimdShape API

SimdShape needs:

  • a constructor taking the following arguments:
    • (mandatory) an elwidth Signal
    • (optional) an integer vector width or a dictionary of vector widths (the keys to be the "elwidth")
    • (mandatory) a dictionary of "partition counts": the keys to again be the "elwidth" and the values to be the number of Vector Elements at that elwidth
    • (optional) a "fixed width" which if given shall auto-compute the dictionary of Vector Widths
    • (mandatory) a "signed" boolean argument which defaults to False
  • To derive from Shape, where the (above) constructor passes it the following arguments:
    • the signed argument. this is simply passed in, unmodified.
    • a width argument. this will be either the fixed_width parameter from the constructor (if given) or it will be the calculated value sufficient to store all partitions.
  • a suite of operators (__add__, etc) that shall take simple integer arguments and perform the computations on every one of the dictionary of Vector widths (examples below)
  • a "recalculate" function (currently known as layout() in layout_experiment.py) which creates information required by PartitionedSignal.
  • a function which computes and returns a suite of PartitionPoints as well as an "Adapter" instance, for use by PartitionedSignal

Examples of the operator usage:

x = SimdShape(vec_op_widths={0b00: 64, 0b01:32, 0b10: 16})
y = x + 5
print(y.vec_op_widths)
{0b00: 69, 0b01: 37, 0b10: 21}

In other words, when requesting 5 to be added to x, every single one of the Vector Element widths had 5 added to it. If the partition counts were 2x for 0b00 and 4x for 0b01 then this would create 2x 69-bit and 4x 37-bit Vector Elements.

Adapter API

The Adapter API performs a specific job of letting SimdSignal know the relationship between the supported "configuration" options that a SimdSignal must have, and the actual PartitionPoints bits that must be set or cleared in order to have the SimdSignal cut itself into the required sub-sections. This information comes from SimdShape but the adapter is not part of SimdShape because there can be more than one type of Adapter Mode, depending on SimdShape input parameters.

class PartType: # TODO decide name
    def __init__(self, psig):
        self.psig = psig
    def get_mask(self):
    def get_switch(self):
    def get_cases(self):
    @property
    def blanklanes(self):