本文适合已经掌握了一门编程语言的读者, 如果没有编程基础, 建议先简单学习下C语言或者在阅读本文的过程中自行查阅相关资料. 本文的用例不会特别多, 更多的代码例子可以查看examples, tests/prajna_sources和builtin_papckages三个目录, 里面有丰富的Prajna代码. 如果你已经掌握了C++, 作者也建议你适当阅读prajna下面的编译器实现(代码非常精简, 也就1万多行代码), 这有利于你精通Prajna.
Prajna编程语言是玄青矩阵推出的一门通用编程语言. Prajna是一门类C编程语言, 其语法简洁性能出色. 其设计目标是同时覆盖Python和C++的主要应用场景. 下面我们介绍一下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推崇最根本, 最简洁的编程理念, 这在我们后面的进一步介绍中会得到体现.
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 # 我们可以通过改指令来运行测试, 这是非必须的步骤
如果是windows平台, 需要进入脚本"C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars64.bat"后进入bash后, 再执行脚本. windows的bash可以通过安装git时勾选git-bash获得.
我们的编译得到的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 ]
还可以通过prajna文件直接执行, 需要文件里包含一个Main函数, 例如examples/hello_world.prajna的代码为
func Main(){
"Hello World\n".PrintLine();
}
我们通过下面的指令执行
prajna exe examples/hello_world.prajna
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是条件分支, 其使用和C是一样的.
var a = 3;
var re = 0;
if (a > 0) {
re = 1;
} else {
re = -1;
}
while也是和C一样的用法
var i = 0;
while (i < 100) {
i = i + 1;
}
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__函数;
- 当变量退出作用域时会触发__finalize__函数;
- 当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来作为包管理系统, 后面我们会给出关于包管理最佳实践. 现在暂时手动拷贝一下文件夹.
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指令, 一般在交互环境中使用
这章我们会介绍一下如何在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通讯库.
由于Prajna初期不具备完善的生态, 我们也提供了在C++里调用Prajna的例子, 这样我们就可以先使用Prajna来实现一部分功能. 该例子在examples_in_cpp里, 若要脱离Prajna的源码使用, 可能自己需要做适当的修改.