[TableGen] Introduce function and lambda

Hi all!

We are trying to introduce function and lambda into TableGen in ⚙ D148915 [TableGen] Introduce function and lambda.
The original need come from modeling RISCV Vector Scheduler in ⚙ D146198 [RISCV] Make Latency/ResourceCycles relevant to LMUL. Though we can define some subroutines via Using Classes as Subroutines, but sometimes we need to pass function(lambda) as template arguments.

We have implemented something like below.

1. The design

1.1 Function Type

The type of a function is represented as:

function<rettype[, argtype ]*>

This type represents a function who returns a value of the rettype and may have several argument types argtype (can be empty). The return type and argument type are arbitrary; it can even be another function type.
Two function types are compatible if their return type and argument types are compatible.

1.2 Function

A function statement defines an function that can be called.

FunctionDefinition: "function" FunctionID Function
FunctionID: TokIdentifier
Function: FunctionArgList ":" ReturnType FunctionBody
FunctionArgList: "(" (Declaration ("," `Declaration`)*)? ")"
ReturnType: Type
FunctionBody: "{" FunctionBodyItem+ "}"
FunctionBodyItem: Return
                   :| Defvar
                   :| Assert
Return: "return" Value ";"

A function can be parameterized by a list of “function arguments” whose values can be used in the function’s body.

If a function argument is not assigned a default value with =, it is uninitialized (has the “value” ?) and must be specified in the function argument list when the function is called (required argument). If an argument is assigned a default value, then it need not be specified in the argument list (optional argument). In the declaration, all required function arguments must precede any optional arguments. The function argument default values are evaluated from left to right.

The name of function is treated as a function reference to itself. As a result, the function name literals can be used in arguments as values with compatible function types.

Recursive function is supported(see 3. Example).

1.2 Lambda (anonymous function)

A lambda (or anonymous function) can be defined by keyword function.

LambdaDefinition: "function" Function

A lambda is a function with anonymous name and all captured variables. It can be treated as a function reference to the anonymous function, so it can even be returned.

Beside arguments and variables defined by defvar in inner scope, a lambda can capture outer variables(like iterator of foreach statement; arguments of outer class, multiclass or function; variables defined by defvar in outer scope). In particular, the priorities of variable capture are:

  1. Variables defined by defvar in inner scope.
  2. Arguments of lambda.
  3. Variables defined by defvar in outer scope.
  4. Iterator of foreach statement.
  5. Arguments of outer class, multiclass or function.
  6. Variables in global scope…

Currently, the fields of a class can’t be captured.

1.3 Function call

The function can be called with funcname(args). However, there is a grammar ambiguity between DAG and function call. For example:

  (op1 (op2 a, b), c) <=> (op1(op2 a, b), c)
        ^                     ^
       DAG                   Call

When parsing this expression, we can treat it as a DAG expression with operator op1 and arguments ((op2 a, b) and c). Or, we can treat it as a wrong DAG expression whose operator is a function call op1(op2 a, b) (though its arguments are wrong). The key point here is that we don’t know whether it’s a new DAG expression or a function call when emitting (.

Currently, to resolve this ambiguity and not increase the complexity of TableGen parser, calls inside DAGs should add a single quote before arguments, like funcname'(args).

  DAG: (op1 (op2 a, b), c)
  Call: (op1 funcname'(a, b), c)

Note: I am still thinking a more elegant way to solve this ambiguity.

2. The implementation

In short:

  • Add a new RecTy FunctionRecTy to present function types.
  • Add a new TypedInit FuncRefInit to present function references. The captures of lambda are contained.
  • Add a new TypedInit FuncCallInit to present function calls.

In our implementation, structure Record is used to present function/lambda just like normal class or records.

2.1 FunctionRecTy

It’s a new RecTy that contains a return type and a list of RecTy to present argument types.
typeIsConvertibleTo is overrided, so if a FunctionRecTy can be compatible with others if their return types and all argument types are compatible.

2.2 FuncRefInit

It’s a new TypedInit that present function references. It contains a pointer to Record and a list of captured variables. Its type is the function type it is referring to.

Two FuncRefInit of the same Record are different if their captured variables are different. So if part of captured variables are resolved, then it becomes a new function reference(which is used to implement returning lambda).

2.3 FuncCallInit

It’s a new TypedInit that present function references. It contains a pointer to FuncRefInit and a list of actual arguments. Its type is the return type of FuncRefInit.

The execution of a function call is almost like what VarDefInit has done (which can be used in class-based subroutines), except that we will take all captured variables into consideration.

Example

function fib(int n):int{
  return !if(!lt(n, 2),
             1,
             !add(
               fib(!sub(n, 1)),
               fib(!sub(n, 2))
              )
            );
}

foreach n = 0...9 in {
  def "fib_" # n {
    int value = fib(n);
  }
}
// The result is:
// def fib_0 { int value = 1; }
// def fib_1 { int value = 1; }
// def fib_2 { int value = 2; }
// def fib_3 { int value = 3; }
// def fib_4 { int value = 5; }
// def fib_5 { int value = 8; }
// def fib_6 { int value = 13; }
// def fib_7 { int value = 21; }
// def fib_8 { int value = 34; }
// def fib_9 { int value = 55; }

function func_return_lambda(int func_arg): function<int, int> {
  return function(int lambda_arg): int {
    return !add(func_arg, lambda_arg);
  };
}

def test {
  defvar lambda0 = func_return_lambda(233);
  defvar lambda1 = func_return_lambda(2333);
  int value0 = lambda0(233);
  int value1 = lambda1(2333);
}

// def test {
//   int value0 = 466;
//   int value1 = 4666;
// }

// ---- Object-Oriented Programming ---- //
class Shape {
  function<int> getArea;
}

class Square<int l>: Shape {
  int length = l;
  let getArea = function():int{
    return !mul(l, l);
  };
}

class Triangle<int b, int h>: Shape {
  int base = b;
  int height = h;
  let getArea = function():int{
    return !div(!mul(b, h), 2);
  };
}

def square:Square<4>;
def triangle:Triangle<4, 4>;

class TestGetArea<Shape shape> {
  int value = shape.getArea();
}

def area_of_square: TestGetArea<square>;
def area_of_triangle: TestGetArea<triangle>;
// The result is:
// def area_of_square { int value = 16; }
// def area_of_triangle { int value = 8; }

So, if you are interesting in this, please feel free to help reviewing this patch or give some feedbacks.
Thanks!

I’m guessing that you cannot use the same trick because you would have to pass the class definition as an argument, and that doesn’t have a type you can name?

And you won’t have the same type for a class definition with 2 template parameters that are ints, because the internals of the class may be different. So you can’t mix and match like functions could.

Just wondering if you need these additions because it’s impossible without them, or instead very difficult to do and if so, what does that look like.

Do in understand correctly that it would be possible to resolve the ambiguity, but the complexity of doing so may not be worth it? (I don’t know much about parsing, perhaps this would need a “look ahead”)

Is it worth having this funcname'args( everywhere instead of just in a DAG? You don’t need it elsewhere but for consistency’s sake.

I see we have !if and other builtins that look very much like functions already. Is it possible to use the ! syntax here or perhaps something like it? One could end up shadowing a builtin that way of course.

Is this return something new too?

Also could you post an example of what the final tablegen looks like for your use case? So we can see what these changes enable, specifically.

For more details, please see ⚙ D146198 [RISCV] Make Latency/ResourceCycles relevant to LMUL.


Currently, we have something like this to model RISCV Vector instructions’ WriteRes:

multiclass LMULSEWWriteResImpl<string name, list<ProcResourceKind> resources,
                               bit isF = 0> {
  def : WriteRes<!cast<SchedWrite>(name # "_WorstCase"), resources>;
  foreach mx = !if(isF, SchedMxListF, SchedMxList) in {
    foreach sew = !if(isF, SchedSEWSetF<mx>.val, SchedSEWSet<mx>.val) in
      def : WriteRes<!cast<SchedWrite>(name # "_" # mx # "_E" # sew), resources>;
  }
}

As you can see, the real name of SchedWrite is base name concatenating with LMUL mx and Selected Element Width sew (LMUL means how many vector registers the instructions are using). There are some fields in SchedWrite to model the cost of a instruction like Latency and ResourceCycles. For a processor, the Latency and ResourceCycles may be relevant/irrelevant to LMUL/SEW for different instructions. What we want to achieve is something like this:

multiclass LMULSEWWriteResImpl<string name, list<ProcResourceKind> resources,
                               bit isF = 0> {
  def : WriteRes<!cast<SchedWrite>(name # "_WorstCase"), resources>;
  foreach mx = !if(isF, SchedMxListF, SchedMxList) in {
    foreach sew = !if(isF, SchedSEWSetF<mx>.val, SchedSEWSet<mx>.val) in
      def : WriteRes<!cast<SchedWrite>(name # "_" # mx # "_E" # sew), resources>{
        let Latency = /* getLatencyFromLMULAndSEW(LMUL, SEW) */;
        let ResourceCycles = /* getResourceCyclesFromLMULAndSEW(LMUL, SEW) */;
      }
  }
}

The key point here is how to implement these getXXXFromLMULAndSEW (and of course, we should leave the implementation to processor’s scheduling model). We came up with a solution that we generate a list of integers and index them by LMUL/SEW, but it would be too complicated that we need to generate a two-dimension list for Latency and three-dimension list for ResourceCycles(because ResourceCycles itself is a list.). That would be a mess in scheduling model .td files.

So, if we support function/lambda, we can write something like this:

multiclass LMULSEWWriteResImpl<string name, list<ProcResourceKind> resources,
                               function<int, string, int> latency,
                               list<function<int, string, int>> resourceCycles,
                               bit isF = 0> {
  def : WriteRes<!cast<SchedWrite>(name # "_WorstCase"), resources> {
    let Latency = latency("WorstCase");
    let ResourceCycles = !foreach(resourceCycle, resourceCycles, resourceCycle("WorstCase"));
  }
  foreach mx = !if(isF, SchedMxListF, SchedMxList) in {
    foreach sew = !if(isF, SchedSEWSetF<mx>.val, SchedSEWSet<mx>.val) in
      def : WriteRes<!cast<SchedWrite>(name # "_" # mx # "_E" # sew), resources> {
        let Latency = latency(mx, sew);
        let ResourceCycles = !foreach(resourceCycle, resourceCycles, resourceCycle(mx, sew));
      }
  }
}


// -------- In scheduling model -------- //

// Create functions that return fixed cycles.
function fixed(int cycles): function<int, string, int> {
  return function(string lmul, int sew = 0): int {
    return cycles;
  };
}
// Create functions that return multiplier of cycles.
function multiplier(int cycles): function<int, string, int> {
  return function(string lmul, int sew = 0): int {
    return !cond(!eq(lmul, "M1"):  !mul(cycles, 1),
                        !eq(lmul, "M2"):  !mul(cycles, 2),
                        !eq(lmul, "M4"):  !mul(cycles, 4),
                        !eq(lmul, "M8"):  !mul(cycles, 8),
                        !eq(lmul, "MF2"): !mul(cycles, 1),
                        !eq(lmul, "MF4"):!mul(cycles, 1),
                        !eq(lmul, "MF8"): !mul(cycles, 1));
  };
}
defm "": LMULSEWWriteResImpl<"XXX", [a, b, c], fixed(3), [fixed(1), fixed(2), multiplier(3)]>;

Now Latency and ResourceCycles can be computed from user-defined functions.

Yes! That’s exactly the problem we have met.

Currently, the parser of TableGen is a LL(1) parser, it can only look ahead for 1 token. But even we can look ahead for more tokens, we still can’t solve the ambiguity since the grammar is beyond the ability of LL parser.

Actually, the first version of our implementation was in this way. The concern is that when we see !xxx, we don’t know whether it’s a bang operator or user-defined function. It may cause some confusion. And something like these seem to be ugly:

!list[index](a, b);
!record.function_field(a, b);
!function(int a, int b){ /* ... */}(c, d);

But we may reconsider this solution if there is no another way.

Yes, it is a new keyword. It represents a return statement in function.