Skip to content

Tutorial: parametric types and functions

This tutorial demonstrates how types and functions can be parameterized to enable them to work on data of different formats and layouts, e.g., for a function foo to work on both u16 and u32 data types, and anywhere in between.

It's recommended that you're familiar with the concepts in the previous tutorial, "float-to-int conversion" before following this tutorial.

Simple parametrics

Consider the simple example of the umax function in the DSLX standard library:

pub fn umax<N: u32>(x: uN[N], y: uN[N]) -> uN[N] {
  if x > y { x } else { y }
}

Most of this function looks like other DSLX functions you may have seen, except for the new-style parameter, N. The declaration of N inside angle brackets denotes that N is a parametric value whose value is a build-time invariant that will be specified by the caller. In other words, changing regular function parameters pumps different values through the circuit, while changing parametric values changes the circuit itself.

Here, N is used to define the widths of the input and output types. It's plain to see, then, that specifying N = 16 would calculate the maximum of two 16-bit numbers, whereas N = 273 would calculate the maximum of two 273-bit numbers. That being said, the smaller the circuit, the faster, smaller, and lower-power it will be, so N should be as small as possible (but no smaller!).

There are two ways invoke a parametric function: the first is to explicitly specify all parametric values, and the second is to rely on the language to infer them:

Explicit specification:

import std

fn foo(a: u32, b: u16) -> u64 {
  std::umax<u32:64>(a as u64, b as u64)
}

Here, the user has directly told the language what the values of all parametrics are.

Parametric inference:

import std

fn foo(a: u32, b: u16) -> u64 {
  std::umax(a as u64, b as u64)
}

Here, though, the language is able to determine that N is 64, since that matches the types of the arguments to umax, and since both arg types agree. There may be times where inference isn't possible - for example, when there exist parametrics that aren't referenced in the argument list:

fn my_parametric_sum<N: u32>(a: u32, b: u32) -> uN[N] {
  let a_mod = a as uN[N];
  let b_mod = a as uN[N];
  a_mod + b_mod
}

To invoke this function, explicit specification is required.

Derived parametrics

It's common, when using parametric types, to need types similar, but not identical to the parametric type. Consider calculating the unbiased floating-point exponent (from the previous tutorial): while the biased exponent was 8 bits wide, the calculated unbiased exponent was 9 bits wide due to the additional sign bit. In this situation, if EXP_SZ was 8, then it'd be handy to also have a SIGNED_EXP_SZ symbol that was equal to 9. This can be done as follows:

fn unbias_exponent<EXP_SZ: u32, SIGNED_EXP_SZ: u32 = EXP_SZ + u32:1>(
    exp: uN[EXP_SZ]) -> sN[SIGNED_EXP_SZ] {
  exp as sN[SIGNED_EXP_SZ] - sN[SIGNED_EXP_SZ]:???
}

Oh no! Specifying parametrics in this way has revealed a problem! If we parameterize types, then in some situations, we'll need to also parameterize values!

Of course, we'd not be writing this tutorial if that wasn't possible. DSLX supports "constexpr"-style evaluation, whereby constant expressions can be evaluated at interpretation or compilation time. In this case, we just need an expression that can calculate the correct bias adjustment: (sN[SIGNED_EXP_SZ]:1 << (EXP_SZ - u32:1)) - sN[SIGNED_EXP_SZ]:1

This is a bit unwieldy in practice, so we can wrap it in a function:

fn bias_scaler<N: u32, WIDE_N: u32 = {N + u32:1}>() -> sN[WIDE_N] {
  (sN[WIDE_N]:1 << (N - u32:1)) - sN[WIDE_N]:1
}

fn unbias_exponent<EXP_SZ: u32, SIGNED_EXP_SZ: u32 = {EXP_SZ + u32:1}>(
    exp: uN[EXP_SZ]) -> sN[SIGNED_EXP_SZ] {
  exp as sN[SIGNED_EXP_SZ] - bias_scaler<EXP_SZ>()
}

Parameterized float-to-int

Finally, consider the 32-bit float-to-int program from the previous tutorial. That program was restricted to converting from one specific type to another. If, however, we wanted to convert from, say a double to an int32_t, we'd have to write a new function, even though the basic logic would be the same.

Instead, armed with parametrics, we can write a single function to handle all such conversions - even to floating-point formats we haven't considered!

The first step in such a parameterization is to have a working single-typed example, which we take from the previous codelab:

pub struct float32 {
  sign: u1,
  bexp: u8,
  fraction: u23,
}

fn unbias_exponent(exp: u8) -> s9 {
  exp as s9 - s9:127
}

pub fn float_to_int(x: float32) -> s32 {
  let exp = unbias_exponent(x.bexp);

  // Add the implicit leading one.
  // Note that we need to add one bit to the fraction to hold it.
  let fraction = u33:1 << 23 | (x.fraction as u33);

  // Shift the result to the right if the exponent is less than 23.
  let fraction =
      if (exp as u8) < u8:23 { fraction >> (u8:23 - (exp as u8)) }
      else { fraction };

  // Shift the result to the left if the exponent is greater than 23.
  let fraction =
      if (exp as u8) > u8:23 { fraction << ((exp as u8) - u8:23) }
      else { fraction };

  let result = fraction as s32;
  let result = if x.sign { -result } else { result };
  result
}

Next is to identify all types needing parameterization, here being the intended size of the result and the layout of the floating-point type itself; all other types flow from that base definition:

  • exp:float32::bexp` size + 1 sign bit
  • fraction:float32::fraction` size + 1 implicit leading bit

Thus, the struct declaration and function signature will be:

pub struct float<EXP_SZ: u32, FRACTION_SZ: u32> {
  sign: u1,
  bexp: uN[EXP_SZ],
  fraction: uN[FRACTION_SZ],
}

pub fn float_to_int<EXP_SZ: u32, FRACTION_SZ: u32, RESULT_SZ: u32>(
    x: float<EXP_SZ, FRACTION_SZ>) -> sN[RESULT_SZ] {
  ...
}

From there, the rest of the function can be populated by replacing the types in the original implementation with the parameterized ones in the signature:

pub struct float<EXP_SZ: u32, FRACTION_SZ: u32> {
  sign: u1,
  bexp: uN[EXP_SZ],
  fraction: uN[FRACTION_SZ],
}

fn bias_scaler<N: u32, WIDE_N: u32 = {N + u32:1}>() -> sN[WIDE_N] {
  (sN[WIDE_N]:1 << (N - u32:1)) - sN[WIDE_N]:1
}

fn unbias_exponent<EXP_SZ: u32, SIGNED_EXP_SZ: u32 = {EXP_SZ + u32:1}>(
    exp: uN[EXP_SZ]) -> sN[SIGNED_EXP_SZ] {
  exp as sN[SIGNED_EXP_SZ] - bias_scaler<EXP_SZ>()
}

pub fn float_to_int<
    EXP_SZ: u32, FRACTION_SZ: u32, RESULT_SZ: u32,
    WIDE_EXP_SZ: u32 = {EXP_SZ + u32:1},
    WIDE_FRACTION_SZ: u32 = {FRACTION_SZ + u32:1}>(
    x: float<EXP_SZ, FRACTION_SZ>) -> sN[RESULT_SZ] {
  let exp = unbias_exponent(x.bexp);

  let fraction = uN[WIDE_FRACTION_SZ]:1 << FRACTION_SZ |
      (x.fraction as uN[WIDE_FRACTION_SZ]);

  let fraction =
      if (exp as u32) < FRACTION_SZ { fraction >> (FRACTION_SZ - (exp as u32)) }
      else { fraction };

  let fraction =
      if (exp as u32) > FRACTION_SZ { fraction << ((exp as u32) - FRACTION_SZ) }
      else { fraction };

  let result = fraction as sN[RESULT_SZ];
  let result = if x.sign { -result } else { result };
  result
}

Note that unbias_exponent() didn't need type specification, since the type could be inferred from the argument! (Also note that this implementation doesn't contain the fixes from the missing cases from the previous tutorial. Exercise to the reader: apply those fixes here, too!)

This technique underlies all of XLS' floating-point libraries. Common operations are defined in common files, such as apfloat.x (general utilities) or apfloat_add_2.x (two-way addition) or apfloat_fma.x (fused multiply-add). Specializations of the above are then available in, e.g., float32.x, fp32_add_2.x, and fma_32.x, respectively, to hide internal implementation details from end users.

With this technique, you can write single implementations of functionality that can be applicable across all sorts of hardware configurations for minimal additional cost. Try it out! Create an 0xbeef-bit wide floating-point adder!