Last Updated on 2022年9月29日
Tablegen Language Tutorial
很难想象,网络上竟然搜索不到可以称为"教程"的Tablegen资料. 唯一可靠的资料是官方的ProgRef, 作为一个Reference, 它是非常合格的, 详尽而精确, 但是如果把它作为教程来阅读, 则有一些缺点:
- 过于详尽, 即便是一些不太重要的特性,也需要用完整精确的内容来描述.
- 过于严谨, 即便是一些简单的特性,也需要用严格的方式来描述,比如 EBNF 风格的 syntax notation, 至少我的大脑是无法 zero cost 的 parse 这种notation的.
- 内容排布不合理, 一些不重要的特性经常位于较为靠前的位置, 且总结性的内容较少.
为了避免这些问题, 本文将按 Quick Start 风格的 Tutorial 来组织,先熟悉最核心/最重要的概念, 再学习其他的功能. 通过这篇教程,你应该能够
- 阅读现有的绝大部分
.td
文件, 写出语法正确的.td
文件 - 知道该去看ProgRef中的哪一部分来厘清自己的疑惑.
最后, 本文尽可能写的短小, 一方面你可以仅阅读本文快速上手, 另一方面, 如果你希望能完整的学习Tablegen, 阅读本文之后再去看ProgRef也不会显得浪费太多时间, 反而应该会让你看得更加顺畅.
Introduction
Tablegen 自身分为两部分,一部分是Frontend, 一部分是Backend. Frontend负责从.td
文件解析结构化的数据, Backend负责从这些数据中生成新的代码. 也就是说, Tablegen 的总体功能是一个代码生成器. llvm-project 现在内嵌了四个 tablegen 的后端实现, 分别用于llvm,lldb,clang和mlir.
不同的后端对前端数据的解释方法非常不同, 这只能去看后端相关的文档, 本文主要关注前端的DSL, 描述tablegen解析.td
文件的DSL规则,而不涉及后端的代码生成.
总的来说, 学习之前应该有一下基本认识:
- 应当把Tablegen DSL视为一种C++语言的变体,尽管你能看到
def
这样的Python关键词. .td
文件仅仅用于记录, 不要把后端的功能和前端的实体绑定起来, 不同的后端可能对同样的数据有非常不同的解释.- 虽然Tablegen声称自己是一种"声明式"语言, 但是仅有涉及field之间的交叉引用时,才是按照依赖顺序处理的,其他场合都可以认为代码是顺序执行的
.td
这个后缀意思是 "target (machine) description", 这对于llvm-tblgen
是非常有意义的. 但对其他后端, 则显得不太合理,我想这也是一个历史问题, 否则可能叫.tg
似乎更加合理.
Quick Start
我们将从下面的例子开始, 逐步介绍 Tablegen.
假设我们现在准备用Tablegen建立一个动物数据库,我们通过这个数据库记录各种动物的信息,其他程序可以通过读取这个"数据库"来获取他们想要的数据,那么我们可能会写出下面这样的例子.
class Animal<string type_ , int type_id_> {
string name = NAME;
string type = type_;
int type_id = type_id_;
}
class Dog : Animal<"Mammalia" , 0> {
string nick_name = ?;
}
def Husky : Dog {
defvar s = "King of Funny";
let nick_name = s;
}
Animal类
class Animal<string type_ , int type_id_> {
string name = NAME;
string type = type_;
int type_id = type_id_;
}
class
是一个用于定义类型的关键词, 可选带有一个<>
包围的模板参数列表, 可选带有一个基类列表, 也可选带有一个{//stmts}
型的init-body.
init-body
init-body
类似于构造函数, 是一段代码,里面主要有三种stmt, 分别是defvar
,let
,filed-def
,
defvar a = b;
用于定义局部变量, 它支持类型推导.defvar
创建的值不允许被修改,defvar
初始化时不能引用field
.Type var_name = init_value;
用于进行field-def
,它将会创建一个新的filed,并用init_value
初始化. (注意,这种形式的stmt只能作为filed-def
使用,所以不会出现在其他scope中.)let c = d;
用于修改已经存在的field
模板参数列表
模板参数列表主要用于传递额外的模板参数, 每个class的模板参数中会有一个隐含的NAME
参数, 这个参数会在class被实例化时传入.
在class Animal
中,我们设计了type_
和type_id_
这两个参数, 在init-body中,使用这两个参数及built-in的NAME
参数创建了3个新的field.
使用模板参数的
<>
符号也额外强调了实参值都是constexpr
基类列表
基类列表用于进行继承,且tablegen允许多继承. 在实例化过程中,基类的init-body
会先于子类执行.
Dog类
class Dog : Animal<"Mammalia" , 0> {
string nick_name = ?;
}
class Dog
继承自class Animal
, 和 C++ 一样, 我们只能继承自具体类, 所以必须在继承时进行特化.
我们在init-body
内为Dog
创建了一个额外的field
,即string nick_name = ?;
, 这里的?
是一个特殊的builtin,它表示一个未初始化的值. 如果你乐意, 加一个模板参数来传递nick_name
也是可以的, 这只是一个风格问题.
Husky实例
def Husky : Dog {
defvar s = "King of Funny";
let nick_name = s;
}
def
是一个用于实例化class
的关键词,def instance_name:ClsName
时,instance_name
是可选的,可选带有一个基类列表,可选带一个init_body.
class
的实例一般被称为concrete record. 如果没有特殊说明,本文之后的record均特指concreate record (这是因为官方的ProgRef还把class
称为abstract record,所以"record"可能会引起混淆)
如果def
时没有给出名字,那么这将创建一个匿名record, 匿名record虽然可以被后端读取到, 但是一般约定后端不对匿名record做任何处理.
def
创建的匿名record一般没有任何作用,因为没有任何方法可以引用它.创建匿名record的另一种方式是手动调用class
, 例如int c = Dog<>.type_id;
或Dog d = Dog<>;
.
需要注意的是, 匿名record总是匿名的,defvar a = Dog<>;
和Dog b = Dog<>
并不能将其转换为具名record, a
和b
仅仅提供了引用的symbol.
def
的基类列表及init-body
和class
功能一致. 在此,为了展示defvar
和let
的用法,我们在def Husky
的init-body
中创建了一个变量s
,并通过let nick_name = "King of Funny";
修改了基类中已经定义的field.
从结果上看,我们创建的Husky record和下面的代码效果基本一致,只不过通过继承创建的recrod会额外记录一个基类列表
def Husky{
string name = "Husky"; // 和实例名一致
string type = "Mammalia";
int type_id = 0;
string nick_name = "King of Funny";
}
Record-Creation:
总的来说,record creation 是两步执行的,第一步递归执行init-body,优先执行基类列表中靠左的class的init-body, 第二步则按照依赖关系更新field之间的交叉引用.
例如,
class X {
int a = 0; // 1. 不涉及field引用, 更新 a 为 0
int b = !add(a,1); // 2. 涉及field引用, 更新 b->a
int c = a; // 3. 涉及 field引用, 更新 c->a
int d = 1; // 4. 不涉及field引用, 更新 d 为 1
}
def x : X{
let a = 11; // 5 不涉及field引用, 更新 a 为11
let c = !add(b,1);// 6. 涉及field引用,更新 c->b
let d = !add(c,1); // 7. 涉及field引用,更新 d->c
defvar const10 = 10;// 8. defvar
int e = !add(d,const10); // 9. 涉及filed引用,更新 e->d
}
例如, 上面的代码在按照1,2,3,4,5,6,7,8,9的顺序执行完之后,所有init-body就执行完成了.
在执行过程中,我们建立了一个依赖树, e->d->c->b->a,为此,我们按照依赖树的顺序来逐步更新结果.
首先,a的值最终是11.
然后,b->a, 它对应了!add(a,1)
,所以计算得到12
然后,c->b, 它对应了!add(b,1)
,所以计算得到13
然后,d->c, 它对应了!add(c,1)
,所以计算得到14
最后是e->d, 它对应了!add(d,const10)
,计算得到24
这样以来,最后的结果就确定了, a=11,b=12,c=13,d=14,e=24
Value
最后,在 Qucik Start 的结尾, 我们需要补充一个已经出现过很多次的concept: "Value".
在Tablegen中,所有的value本质上都是由 constexpr 输出的值, 而形成这些constexpr的基本类型则仅有bit, int, string, bits<N>, list<T>, dag
及 concreate record.
-
bit a = 0;
,只能取0,1的二进制值. -
int a = 0;
,没什么特别的.注意,整数literal可以用0xffff
,0b1111
这样的表示 -
string a = "foo";
,没什么特别的.注意,string literal 有"foo"
,[{foo}]
两种形式,后者类似于python的"""foo"""
,主要功能是用于多行字符串. -
bits<4> a={0,1,0,1};
,代表了若干bit,可以用a{0}
这样的语法访问特定位,a{0..2}
方位特定的一段. -
list<int> a=[1,2,3];
,代表了list,可以用a[0]
,a[0..2]
这样的语法访问. list内的T
可以是自定义的class
,例如list<Animal>
,则此时只能用子类的实例初始化.[]
expression 还支持type-hint,这主要用于空列表, 例如defvar a = []<int>
-
dag a = (record ARGs...);
,形式上是一个括号表达式, 代表了单个dag node, 括号表达式整体代表了dag node的出边.- 括号表达式的第一个元素必须是一个record,然后用空格分开.
ARGs...
是一个逗号表达式,可以有任意多的元素, 代表dag node 的入边, 如果入边是另外一个dag node, 那么就可以形成图的结构了.ARG
的典型形式有三种,value0 , $tag1, value2:$tag2
value0
, 仅使用一个值value0
$tag1
, 仅有一个tag1
标记,而没有值$tag
的$
是必须的,后面的助记符则可以是任意identifier,例如$my_qq
也是可以的
value2:$tag2
,同时有tag2
标记,且有值- dag中的
record
,tag
和value
对前端没有意义,由后端来解释. - 在构建dag时,允许使用类似CRTP的策略
def rec1 : A<(my_op rec1)>;
-
record也是value, 需要value的地方,都可能使用record.(匿名record具名record均可)
其他要点
Preprocessor
tablegen支持include
,其语义和C的#include
完全一致,在命令行中还支持-Ipath/to/search
来动态添加搜索目录.
tablegen支持以#ifdef
为核心的一系列预处理宏,其用法和C也完全一致, 例如实现条件编译及header-guard, 在命令行中也可以通过-D
动态添加定义.
Expression
Tablegen自身没有operator token, 但是支持一系列bang operator, 这些bang operator 可以视为 tablegen 的 built-in method, 例如int Yplus1 = !add(Y, 1);
另外, #
可以作用于string
和list
类型,用于进行concat, 它的规则比较复杂,可以参考ProgRef. 比较典型的应用场景是在defm
中与NAME
配合构造具名record的名字.
init-body 之外的 defvar,let
defvar
功能和init-body
一致,只不过这样创建的var作用域不同.
不在init-body
内的的let
都称为global-let
,它的语法有所不同,具体为
let a=1,b=2 in {
// stmts
}
它的功能为: 对于新scope内的所有class
和def
, 在其init-body
开始的地方插入一系列let
语句,例如
let a=1 in {
class X {
STMTS0
}
def x : X {
STMTS1
}
}
等价为
class X {
let a = 1;
STMTS0;
}
def x : X {
let a = 1;
STMTS1;
}
这种插入行为导致了一个隐形的依赖: let 修改的field
必须在对应的init-body执行时已经存在,如果不存在,前端会直接报错.
multiclass && defm
multiclass
和 defm
实现了 macro 功能, 前者负责定义macro, 后者负责展开macro.
multiclass
定义时也允许传入模板参数,同时允许继承,但是继承的基类必须是multiclass
。
在进行展开时,defm x:X,Y,Z
有一定约束: 只能有一个multiclass
作为实例化类型。换言之,X必须是一个multiclass
,Y,Z等必须是普通class:
defm x:X,Y,Z
展开的规则如下
- Step1: X 的init-body内,所有
def/defm
都会依照name-mangle来直接展开,X的基类直接作为为un-mangle的defm
插入到展开的结果末尾 - Step2: Y,Z等普通类型将作为新的基类插入到展开后的指令中
- Step3: 如果仍有defm,递归执行上述过程
例如,对于下面的例子
class Core{
string name0 = NAME#core;
}
class SomeCls{
string name1 = NAME#SomeCls;
}
multiclass FooBase{
def _a:Core;
}
multiclass FooMember{
def _b:Core;
}
multiclass Foo : FooBase{
def _c;
defm _d:FooMember;
}
defm foo : Foo,SomeCls;
那么对于defm foo : Foo, SomeCls;
,它的展开过程如下,
- 将 Foo 的init-body直接展开为
def foo_c; defm foo_d:FooMember
- 由于Foo还有基类FooBase,所以需要继续对基类进行
defm
插入,得到def foo_c; defm foo_d:FooMember; defm foo:FooBase;
- 由于SomeCls的存在,所以需要插入到展开后的实例中,得到
def foo_c:SomeCls; defm foo_d:FooMember,SomeCls; defm foo:FooBase,SomeCls;
- 递归的继续对新产生的
defm foo_d:FooMember,SomeCls;
和defm foo:FooBase,SomeCls;
进行展开。
最终展开的结果为:
def foo_a { // Core SomeCls
string name0 = "foo_acore";
string name1 = "foo_aSomeCls";
}
def foo_c { // SomeCls
string name1 = "foo_cSomeCls";
}
def foo_d_b { // Core SomeCls
string name0 = "foo_d_bcore";
string name1 = "foo_d_bSomeCls";
}
在需要时,我们可以在multiclass
的body内手动使用NAME
进行mangle,这个NAME
是defm
时传入的, 手动mangle会抑制自动mangle机制.例如
multiclass Foo{
def a_#NAME;
}
defm foo:Foo;
展开后为def a_foo;
defset,if,foreach,assert
defset
是一个语法糖,它可以自动把一系列record收集到一个列表var里,例如
defset list animal_list = {
def a:Animal;
def b:Animal;
}
// 等价于
def a:Animal;
def b:Animal;
defvar animal_list = [a,b];
if, foreach, assert 的功能则和你想象的一模一样, 在需要时再直接看文档即可.