Skip to content

Latest commit

 

History

History
776 lines (555 loc) · 20.9 KB

般若编程语言指南.md

File metadata and controls

776 lines (555 loc) · 20.9 KB

般若编程语言Prajna

前言

本文适合已经掌握了一门编程语言的读者, 如果没有编程基础, 建议先简单学习下C语言或者在阅读本文的过程中自行查阅相关资料. 本文的用例不会特别多, 更多的代码例子可以查看examples, tests/prajna_sources和builtin_papckages三个目录, 里面有丰富的Prajna代码. 如果你已经掌握了C++, 作者也建议你适当阅读prajna下面的编译器实现(代码非常精简, 也就1万多行代码), 这有利于你精通Prajna.

简介

Prajna编程语言是玄青矩阵推出的一门通用编程语言. Prajna是一门类C编程语言, 其语法简洁性能出色. 其设计目标是同时覆盖Python和C++的主要应用场景. 下面我们介绍一下Prajan主要特点.

Prajan的主要特点

各平台直接运行

Prajna采用即时编译方式, 无需事先编译为二进制可执行程序. 代码可直接在X86, Arm和RiscV等各种指令集的芯片上直接运行.

友好交互

Prajan支持Main函数, Repl和Jupyter等多种运行方式, 适合软件的研发和部署等多种场景.

极致性能

Prajna使用LLVM作为后端, 同等条件下性能不弱于C++和CUDA.

异构编程

不同于OpenCL和CUDA在C++上的拓展, Prajna是一个原生支持(基于核函数的)并行编程的语言, 它对GPGPU并行编程的支持会比CUDA更加友好方便, 也会支持更多的GPGPU(目前仅支持Nvidia).

内置张量优化编译器

在下一阶段Prajna会从语言层面集成一个自研的张量优化(暂叫波罗蜜多)编译器, 会对张量计算提供极具竞争力的解决方案, 兼容众多NPU/TPU.

简洁的编程理念

Prajna不会强调面向对象和函数式编程等花里胡哨的编程概念. Prajna推崇最根本, 最简洁的编程理念, 这在我们后面的进一步介绍中会得到体现.

入门指南

源码安装

Ubuntu 20.04 需要安装的一些依赖库

apt install git clang wget libgnutls28-dev libsodium-dev uuid-dev build-essential libssl-dev
Install CMake 2.30.0 参考: https://gist.github.com/bmegli/4049b7394f9cfa016c24ed67e5041930

下载源码

首先我们下载源码, 下载的库会比较多, 如果没有报错请耐心等待, 建议配置网络代理以便能流畅下载github的代码

# 下载代码
git clone https://github.com/matazure/prajna.git
# 下载依赖库
./scripts/clone_submodules.sh --jobs=16 --depth=100

如果在下载的代码中出现错误, 可以自行处理出了问题的submodule. 主要原因是depth的深度不能包含所需的commit. 可以用下面的执行下载完整的仓库.

./scritps/clone_submodules.sh --force --jobs=8

编译

可以使用docker的环境来编译代码, 也可以自行参阅dockerfile来配置环境. 值得注意的是目前Prajna只支持Clang的编译器, 若使用GCC或其他编译器可能需要自己适配.

./scripts/start_docker_env.sh
./scripts/configure.sh release # 配置为release模式
./scripts/build.sh release
./scripts/test.sh release # 我们可以通过改指令来运行测试, 这是非必须的步骤

安装

我们的编译得到的build_release/install目录就是我们的安装包, 将build_release/install/bin加入到我们的可执行路径里即可.

获取预编译程序

我们也可以通过https://github.com/prajna-lang/prajna/releases直接获取主要平台的二进制文件, 然后将bin目录加入到可执行路径里.

运行方式

Prajna提供多种运行方式, 可以在不同场景下选择合适的模式运行

命令行模式

般若可以通过下面指令进入命令行模式

prajna repl

我们可以像Python命令行一样使用Prajna,

(base) ➜  local prajna repl
Prajna 0.0.0, all copyrights @ "www.github.com/matazure"
prajna > "Hello World!"                                                  1,15  ]
Hello World!
prajna >                                                                 1,1   ]

执行Main函数

还可以通过prajna文件直接执行, 需要文件里包含一个Main函数, 例如examples/hello_world.prajna的代码为

func Main(){
    "Hello World\n".PrintLine();
}

我们通过下面的指令执行

prajna exe examples/hello_world.prajna

Jupyter

Prajna支持Jupyter模式. 首先我们需要先安装Jupyter, 具体可参阅其官网介绍https://jupyter.org/; 然后我们可以通过以下的命令来安装Prajna Kernel.

prajna jupyter --install

完成上述步骤后, 我们就可以在Jupyter的环境里使用Prajna来进行编程了.

编程基础

这章我们介绍一些编程的基本概念, 这些概念在其他语言中也是通用的.

变量

在Prajna中我们如下定义一个变量. 变量需要指定名字和类型. 变量定义时可以提供初始值, 这时类型可以省略.

var tmp0 : i64;
var tmp1 : i64 = 10i64;
var tmp2 = 100i64; // 类型可以根据初始推到

在使用变量前必须使用var来定义它, 冒号后面是该变量的类型. 我们可以通过等号"="来给一个变量赋值, Prajna的赋值操作不存在隐式转换, 需要它们的类型完全一致.

var tmp: f32;
tmp = 3.1415f32;

类型

Prajna是一门静态强类型编程语言. "静态"指数据的类型在编译时期就是决定的, 且不会在运行期改变. "强"表示Prajna有着严格的数据类型管理, 不存在隐式转换等非显著的类型转换. 我们首先介绍两种数值类型, 也就是整型和浮点型.

整型类型

整型也就是数学里的整数所对应的类型, 其没有小数部分. Prajna的整型包括有符号和无符号两种.

var a: i64 = 3i64; // i表示有符号, 64表示是64位整型
var b = 128u32; // u表示无符号
var c = 1024; // 默认为64位有符号整型

下面是Prajna整型的支持列表.

类型 有无符号 位数
i8 8位
i16 16位
i32 32位
i64 64位
u8 8位
u16 16位
u32 32位
u64 64位

值得一提是Prajna支持自定义位宽, 我们可以使用任意位宽的整型.

var big_num: Int<256>; // 定义一个256位宽的有符号整型
var big_num2: Unt<256>; // 定义一个256位宽的无符号整型

关于不同整型的编码方式和范围, 可自行查阅相关资料.

浮点型类型

浮点型是有小数的数值类型, 并且其小数的数位是可变的.

var pi_f32 = 3.1415f32; // 32位浮点数
var pi_f16 = 3.1415f16; // 16位浮点数
var pi = 3.1415; // 默认为32位浮点数

下面是Prajna目前支持的浮点型,

类型 位数
f16 16位
f32 32位
f64 64位

对于整型和浮点型常量, 我们需要在数值的后面加上类型后缀以便以区分, 若没有后缀则默认表示i64和f32类型

上述两种数据类型都支持算术运算

算术运算

Prajna的数值类型支持加减乘除的算术运算, 这和我们数学上的符号是一致的, 如下所示

var a = 3 + 4;
var b = 3.1415f32 * 2f32;

Prajna只支持相同类型的算术运算, 不同类型需要显示转换类型

布尔型列表

Prajna使用bool来表示布尔类型, 其两个值分别是true和false.

类型 标识符
bool true
bool false

布尔类型支持完备的逻辑运算

var tmp0 = true && false; // 与运算
var tmp1  = false || true; // 或
var tmp2 = !false; // 非
var tmp3 = true ^ false; // 异或

指针

我们用ptr<i64>来表示指针, ptr<Type>就相当于C里的Type *.

var tmp = 1024;
var p: ptr<i64> = &tmp; // 获取临时变量的地址
*p = 1025; // 通过解指针来给地址赋值

更多过于指针的操作可查阅源码里的tests/prajna_sources/ptr_test.prajna文件, 或者在builtin_packages里查看更多使用方法.

数组

我们用array<Type, Length>来表示C里的数组, 相当于"Type tmp[Length]";

var array_tmp: array<i64, 3> // 类型为i64, 长度为3的数组
var array_tmp = [1.0, 2.0, 3.0]; // [1.0, 2.0, 3.0]会解析为类型为f32, 长度为3的数组,且其元素依次为1.0, 2.0, 3.0;

函数

函数是类C编程语言的重要概念, 般若的函数定义如下所示

func Add(v0: i64, v1: i64) -> i64 {
    return v0 + v1;
}

func PrintHi() { // 不存在返回值类型
    "Hi".Print();
}

func是用来声明函数的关键字, 紧接着是v0和v1两个参数, 参数后面紧接着的是参数类型, 最后"->"后面是返回值类型. 像C语言一样, 般若函数的主体由多个语句组成, 语句可以是声明和表达式.

控制流

般若提供if-else, while和for三种常见的控制流.

if-else

if-else是条件分支, 其使用和C是一样的.

var a = 3;
var re = 0;
if (a > 0) {
    re = 1;
} else {
    re = -1;
}

while

while也是和C一样的用法

var i = 0;
while (i < 100) {
    i = i + 1;
}

for

for的使用和C是不同, 我们这样使用它

var sum = 0;
for i in 0 to 100 { // i会在[0, 100)的区间遍历, 不包含100.
    sum = sum + i;
}

注释

Prajna的注释和C是一样的.

// 单行注释
/*
多行
注释
*/

结构

Prajna可以像下面这样定义自己的结构类型.

struct Point{
    x: f32;
    y: f32;
}

我们可以通过"."(索引)算子来访问结构类型的字段.

var origin: Point;
origin.x = 0.0;
origin.y = 0.0;

成员函数

我们可以为类型定义成员函数, 这里我们借鉴了Rust, Swift的思想. 我们不把成员函数定义的结构类型里, 而是在外部去拓展它.

implemnt Point{
    func Norm()->f32{
        return this.x * this.x + this.y * this.y; // this对象相当于指向自身的一个变量
    }
}

成员函数里会有一个this对象, 和C++不同的是, Prajna里的this对象相当于指向自身的一个变量. 我们一般如下调用成员函数

var dis = Point(100.0, 100.0);
var Norm = dis.Norm();

某种意义上我们可以把成员函数理解为普通函数的一个语法糖, 它隐式传递了一个this对象.

静态成员函数

通过@static修饰可以为结构定义静态成员函数, 这样我们可以像普通函数一样使用它.

implement Point{
    @static
    func Create() {
        var self: Point;
        self.x = 0.0;
        self.y = 0.0;
        return self;
    }
}

var zero_point = Point::Create();

静态成员函数和普通函数的区别就是它们所在的域不一样. 静态成员函数我们通过结构来获取它.

运算符

Prajna通过特别命名的函数来定义运算符号, 不同运算所对应的名字如下所示

二元运算符

函数名 运算符
__equal__ ==
__not_equal__ !=
__less__ <
__less_or_equal__ <=
__greater__ >
__greater_or_equal__ >=
__add__ +
__sub__ -
__multiply__ *
__divide__ /
__remaind__ %
__and__ &
__or__ |
__xor__ ^

一元前缀运算符

函数名 一元前缀运算符
__not__ !
__positive__ +
__negative__ -

可参阅builtin_packages/primitive_type.prajna里有很多运算符的实现.

属性

Prajna支持属性, 我们可以通过__set__和__get__前缀的函数来实现.

struct People{
    _age: i64;
}

implement People{
    func __get__Age()->i64{  // 定义属性的赋值函数
        return this._age;
    }

    func __set__Age(v: i64){  // 定义属性的取值函数
        this._age = v;
    }
}

func Main(){
    var people: People;
    people.Age =  18; // 等价于 peeple.__set_Age(18);
    var age = people.Age; // 等价于 var age = people.__get_Age();
}

属性也是一个函数调用的语法糖, 简化了set/get的语法. 除此之外我们通过属性支持了"[]"下标索引运算符, 可以直接看系统库里的相关实现.

资源管理

资源管理是现代编程语言的一个重要组成部分, 我们引入自动机制来实现Prajna对资源的自动管理.

我们内置了Ptr智能指针, 它基于"引用计数"实现了对内存的自动管理.

智能指针

func Main() {
    var mem = Ptr<i64>::Allocate(1024); // 申请内存
    mem.ReferenceCount().PrintLine(); // 引用计数为1
    {
        var t = mem;
        mem.ReferenceCount().PrintLine(); // 引用计数为2
    }
    mem.ReferenceCount().PrintLine(); // 引用计数为1
    // 退出该块时mem的引用计数为0, 释放该内存
}

__intialize__,__copy__和__finalize__

这三个函数是自动触发的回调函数,

  1. 我们定义一个变量时会触发__intialize__;
  2. 当对变量进行赋值时会触发右值的__copy__函数和左值的__finalize__函数;
  3. 当变量退出作用域时会触发__finalize__函数;
  4. 当return一个值时, 会触发其__copy__函数;

我们的智能指针, String, List等的资源管理都是利用上述机制来实现的.

没有完全自动化和可靠的资源管理方式, Prajna的资源管理把"清晰的规则"放在首位.

单元

Prajna使用单元(Module)概念来组织程序, 其有点类似C++里的namespace, 不同的是Module会根据文件名自动生成单元名, 也就是说大部分时候不建议你手动创建单元.

文件路径test1/test2/test3.prajna对应的单元路径是test1::test2::test3. 我们子单元可以访问父单元里的符号(函数和结构等).

值得注意的是".prajna"不会生成单元名. test/.prajna和test.prajna的单元组织是一样的, 都是test;

我们当然也可以通过module来创建单元.

module A {
    func Test() {}
}

A::Test(); //我们通过::来获取单元里的函数等

use A::Test; // 将Test符号导入到当前单元里
Test(); // 可以直接使用

use A::Test as A_Test; // 将Test符号导入到当前单元里命名为A_Test;
A_Test();

接口

Prajna和C#, Rust等程序一样拥有interface机制, 它和C++里的虚函数基本原理是一样的. 接口是为了更好的实现动态分发, 也就是我们常说的多态.

interface Say{
    func Say();
}

struct SayHi{
    name: String;
}

implement Say for SayHi{
    func Say() {
        "Hi ".Print();
        this.name.PrintLine();
    }
}

struct SayHello{
    name: String;
}

implement Say for SayHello{
    func Say(){
        "Hello ".Print();
        this.name.PrintLine();
    }
}

func Main(){
    var say_hi = Ptr<SayHi>::New();
    say_hi.name = "Prajna";
    say_hi.Say();
    var d_say: Dynamic<Say> = say_hi.As<Say>(); //Dynamic<Say>也是一个具体的类型, 其会存储Say接口的函数
    d_say.Say();

    var say_hello = Ptr<SayHello>::New();
    say_hello.name = "Paramita";
    say_hello.Say();
    d_say = say_hello.As<Say>();
    d_say.Say();

    // 需要先判断再做转换, 不同于C++, 如果转换类型不合法会直接报错退出
    if (d_say.Is<SayHi>()) {
        var say_hi2 = d_say.Cast<SayHi>();
        say_hi2.Say();
        say_hi.name = "Prajna2 ";
    } {
        var say_hello2 = d_say.Cast<SayHello>();
        say_hello2.Say();
        say_hello2.name = "Paramita2 ";
    }

    d_say.Say();
}

通过上述例子, 可以看出Prajna的多态在语法上做了些改善.

模板

Prajna的模板由两部分构成, 一部分是模板参数, 另一部分是代码. 当我们使用模板时, 我们会给模板传入具体的符号实参, 这时候编译器就可以编译模板的代码了.

模板结构

我们可以像下面这样使用模板结构

template <ValueType> // ValueType为模板参数
struct Point{
    x: ValueType;
    y: ValueType;
}

template <ValueType>
implement Point<ValueType> { // Point需要带上模板参数, 不可省略的.
    func Norm2()->ValueType {
        return (this.x * this.x + this.y * this.y).Sqrt();
    }
}

func Main() {
    var point_f32: Point<f32>;
    point_f32.x = 1.0;
    point_f32.y = 2.0;
    point_f32.Norm2().PrintLine();
}

模板函数

这是一个模板函数的例子

template <Type>
func Add(v0: Type, v1: Type)->Type{
    return v0 + v1;
}

func Main() {
    Add<i32>(0i32, 1i32).PrintLine(); // 不同于其他编程语言, Prajna的模板需要显示模板实参
    Add<f32>(2f32, 3f32).PrintLine();
}

虽然般若里有多种模板存在, 但模板的规则并不复杂,

闭包

Prajna支持闭包(匿名函数), 闭包是典型的较为复杂的语法糖.

func Main() {
    var f = (){
        "Hello World!".PrintLine();
    };
    f();

    var add = (a: i64, b: i64)->i64 {
        return a + b;
    };
    add(2, 3).PrintLine();

    var x = 100;
    var capture_add = (v: i64)->i64 {
        return v + x; // closure会自行捕获所使用的值, 这里我们捕获了x.
    };
    capture_add(10).PrintLine();
}

包管理

Prajna建议直接使用git来作为包管理系统, 后面我们会给出关于包管理最佳实践. 现在暂时手动拷贝一下文件夹.

GPU并行编程

Prajna的GPU并行编程和CUDA/OpenCL里的概念是基本一致的. 如果没有CUDA/OpenCL的基础, 可以先查阅一下相关资料.

use ::gpu::*;
use ::gpu::Tensor<f32, 2> as GpuMatrixf32;

@kernel // 标注核函数
@target("nvptx")
func MatrixMultiply(A: GpuMatrixf32, B: GpuMatrixf32, C: GpuMatrixf32) {
    var thread_x = ::gpu::ThreadIndex()[1];
    var thread_y = ::gpu::ThreadIndex()[2];
    var block_x = ::gpu::BlockIndex()[1];
    var block_y = ::gpu::BlockIndex()[2];
    var block_size = 32;
    var global_x = block_x * block_size + thread_x;
    var global_y = block_y * block_size + thread_y;

    var sum = 0.0f32;
    var step = A.Shape()[1] / block_size;
    for i in 0 to step {
        @shared
        var local_a: Array<f32, 1024>;
        @shared
        var local_b: Array<f32, 1024>;
        local_a[thread_x* 32 + thread_y] = A[global_x, thread_y + i * block_size];
        local_b[thread_x* 32 + thread_y] = B[thread_x + i * block_size , global_y];
        ::gpu::BlockBarrier();

        for j in 0 to 32 {
          sum = sum + local_a[thread_x * 32 + j] * local_b[j * 32 + thread_y];
        }
        ::gpu::BlockBarrier();
    }

    C[global_x, global_y] = sum;
}

@test
func Main() {
    var block_size = 32;
    var block_shape = [1, block_size, block_size]; // 注意和cuda的dim是相反的顺序, [z, y, x]
    var a_shape = [10 * 32, 10 * 32];
    var b_shape = [10 * 32, 20 * 32];
    var grid_shape = [1, a_shape[0] / block_size, b_shape[1] / block_size];

    var A = GpuMatrixf32::Create(a_shape);
    var B = GpuMatrixf32::Create(b_shape);
    var C = GpuMatrixf32::Create([a_shape[0], b_shape[1]]);

    MatrixMultiply<|grid_shape, block_shape|>(A, B, C);
    cuda::cudaDeviceSynchronize(); // 后面会改为更为通用的名字

    var epoch = 300;
    var t0 = chrono::Clock();

    for i in 0 to epoch {
      MatrixMultiply<|grid_shape, block_shape|>(A, B, C); // 核函数调用, 语法和cuda相比有所改进.
    }
    cuda::cudaDeviceSynchronize(); // 后面会改为更为通用的名字

    var t1 = chrono::Clock();
    t0.PrintLine();
    t1.PrintLine();

    var flops = 2 * a_shape[0] * a_shape[1] * b_shape[1];
    var giga_flops = (flops.Cast<f32>() * 1.0e-9 * epoch.Cast<f32>()) / (t1 - t0);
    giga_flops.Print();
    "GFlop/s".PrintLine();
}

特殊指令

Prajna里支持以"#"开头的特殊指令, 主要是在编译器执行一些操作.

#error("..."); // 输出错误信息
#warning("...") // 输出警告信息
#system("...") // 执行terminal指令, 一般在交互环境中使用

使用C函数

这章我们会介绍一下如何在Prajna里使用C函数, 这让我们可以使用庞大的C/C++的基础库.

外部函数

我们可以通过@extern来标注一个函数, 它会强制让该函数的符号名就是函数名本身. 比如我们在test.prajna定义下面的函数

func TestA(); // 该函数的符号为::test::TestA, 这个符号是无法找到对应的C函数的

@extern
func TestB(); // 该函数的符号为TestB, 这个符号可以对应到C函数.

我们声明了一个外部函数后, 我们需要把包含该符号的动态库加载进来, 这样这个函数才能正常加载使用.

加载动态库

通过#link加载一个名为libzmq的动态库,

#link("libzmq");

我们需要在libs目录下放置不同平台的动态库, 路径和命名如下所示.

libs/
├── linux/
│   └── libzmq.so
├── osx/
│   └── libzmq.dylib
└── win/
    └── libzmq.dll

可以去examples/zmq下查看完整的例子, 该示例展示了如何在Prajna里使用C的libzmq库, 该库后期也会作为Prajna的socket通讯库.

在C++中使用Prajna

由于Prajna初期不具备完善的生态, 我们也提供了在C++里调用Prajna的例子, 这样我们就可以先使用Prajna来实现一部分功能. 该例子在examples_in_cpp里, 若要脱离Prajna的源码使用, 可能自己需要做适当的修改.