深入探索如何在 MoonBit 中实现 Haskell 求值语义(三)

2024-06-13 19:28

本文主要是介绍深入探索如何在 MoonBit 中实现 Haskell 求值语义(三),希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

本期文章为在MoonBit中实现惰性求值的第三篇。在上一篇中,我们了解了let表达式的编译方法以及如何实现基本的算术比较操作。这一篇文章中,我们将实现一种基于上下文的优化方法,并添加对数据结构的支持。

追踪上下文

回顾一下我们之前实现primitive的方法:

let compiledPrimitives : List[(String, Int, List[Instruction])] = List::[// 算术("add", 2, List::[Push(1), Eval, Push(1), Eval, Add, Update(2), Pop(2), Unwind]),("sub", 2, List::[Push(1), Eval, Push(1), Eval, Sub, Update(2), Pop(2), Unwind]),("mul", 2, List::[Push(1), Eval, Push(1), Eval, Mul, Update(2), Pop(2), Unwind]),("div", 2, List::[Push(1), Eval, Push(1), Eval, Div, Update(2), Pop(2), Unwind]),// 比较("eq",  2, List::[Push(1), Eval, Push(1), Eval, Eq,  Update(2), Pop(2), Unwind]),("neq", 2, List::[Push(1), Eval, Push(1), Eval, Ne,  Update(2), Pop(2), Unwind]),("ge",  2, List::[Push(1), Eval, Push(1), Eval, Ge,  Update(2), Pop(2), Unwind]),("gt",  2, List::[Push(1), Eval, Push(1), Eval, Gt,  Update(2), Pop(2), Unwind]),("le",  2, List::[Push(1), Eval, Push(1), Eval, Le,  Update(2), Pop(2), Unwind]),("lt",  2, List::[Push(1), Eval, Push(1), Eval, Lt,  Update(2), Pop(2), Unwind]),// 杂项("negate", 1, List::[Push(0), Eval, Neg, Update(1), Pop(1), Unwind]),("if",     3,  List::[Push(0), Eval, Cond(List::[Push(1)], List::[Push(2)]), Update(3), Pop(3), Unwind])
]

这样的实现引入了很多Eval指令,但它们未必总是用得上。例如:

(add 3 (mul 4 5))

add的两个参数在执行Eval之前就已经是WHNF, 这里的Eval指令是多余的。

一种可行的优化方法是在编译表达式时注意其上下文。例如,add需要它的参数被求值成WHNF,那么它的参数在编译时就处于严格(Strict)上下文中。通过这种方式,我们可以识别出一部分可以安全地按照严格求值进行编译的表达式(仅有一部分)

  • 一个超组合子定义中的表达式处于严格上下文中

  • 如果(op e1 e2)处于严格上下文中(此处op是一个primitive),那么e1e2也处于严格上下文中

  • 如果(let (.....) e)处于严格上下文中,那么e也处于严格上下文中(但是前面的局部变量对应的表达式就不是,因为e不一定需要它们的结果)

我们用函数compileE实现这种严格求值上下文下的编译,它所生成的指令可以保证栈顶地址指向的值一定是一个WHNF

首先对于默认分支,我们仅仅在compileC的结果后面加一条Eval指令

append(compileC(self, env), List::[Eval])

常数则直接push

Num(n) => List::[PushInt(n)]

对于let/letrec表达式,之前特意设计的compileLetcompileLetrec便起到用处了,编译一个严格上下文中的let/letrec表达式只需要用compileE编译其主表达式即可

Let(rec, defs, e) => {if rec {compileLetrec(compileE, defs, e, env)} else {compileLet(compileE, defs, e, env)}
}

ifnegate的参数数量分别为3、1, 需要单独处理。

App(App(App(Var("if"), b), e1), e2) => {let condition = compileE(b, env)let branch1 = compileE(e1, env)let branch2 = compileE(e2, env)append(condition, List::[Cond(branch1, branch2)])
}
App(Var("negate"), e) => {append(compileE(e, env), List::[Neg])
}

基础的二元运算则可以通过查表统一处理, 首先构建一个叫做builtinOpS的哈希表,它允许我们通过primitive的名字查询对应指令。

let builtinOpS : RHTable[String, Instruction] = {let table : RHTable[String, Instruction] = RHTable::new(50)table["add"] = Add table["mul"] = Multable["sub"] = Subtable["div"] = Divtable["eq"]  = Eqtable["neq"] = Netable["ge"] = Ge table["gt"] = Gttable["le"] = Letable["lt"] = Lttable
}

其余处理则没有太多区别。

App(App(Var(op), e0), e1) => {match builtinOpS[op] {None => append(compileC(self, env), List::[Eval]) // 不是primitive op, 走默认分支Some(instr) => {let code1 = compileE(e1, env)let code0 = compileE(e0, argOffset(1, env))append(code1, append(code0, List::[instr]))}}
}

大功告成了吗?好像是的,不过,除了整数,其实还有另外一种WHNF: 偏应用(partial application)的函数

所谓偏应用,就是指参数数量不足。这种情况常见于高阶函数,例如

(map (add 1) listofnumbers)

这里的(add 1)就是一个偏应用.

要让新的编译策略产生的代码不出问题,我们还得修改Unwind指令关于NGlobal分支的实现。在参数数量不足且dump中有保存的栈时,只保留原本的redex并且还原栈。

NGlobal(_, n, c) => {let k = length(self.stack)if k < n {match self.dump {Nil => abort("Unwinding with too few arguments")Cons((i, s), rest) => {// a1 : ...... : ak// ||// ak : s// 保留redex, 还原栈self.stack = append(drop(self.stack, k - 1), s)self.dump = restself.code = i}}} else {......}
}

这种基于上下文的严格性分析技术很有用,但是碰上超组合子调用就什么都做不了了。在此处我们简单介绍一下一种基于布尔运算的严格性分析,它可以分析出对于某个超组合子的调用,哪些参数应该使用严格模式编译。

我们首先定义一个概念:bottom,它是一个概念上代表永不停机/异常的值。对于超组合子f a[1] ...... a[n], 如果有一个参数a[i]满足a[i] = bottomf a[1] .... a[i] .... a[n] = bottom(其他参数都不是bottom),那说明无论f的内部控制流如何复杂,想调用它得到结果一定是需要参数a[i]的,它应该严格求值。

不符合这个条件也不一定是完全不需要,可能只在某个分支中使用了,具体用不用要运行时决定。这种参数是典型的应该惰性求值的例子。

让我们把bottom看作false, 非bottom的值看做true, 这样一来所有coref中的函数都可以看做布尔函数了。以abs为例

(defn abs[n](if (lt n 0) (negate n) n))

我们自顶向下地分析应该怎么翻译成布尔函数

  • 对于(if x y z)而言,x是一定需要计算的,但yz只需要计算一个,那么它被翻译成x and (y or z)。以上面这个函数为例说明,如果n是bottom, 那么条件(lt n 0)也是bottom,则整个表达式的结果也是bottom。
  • 对于primitive直接全用and就好

那么判断一个参数是否需要严格地编译,只需要把上面的条件翻译成布尔函数版:a[i] = falsef a[1] .... a[i] .... a[n] = false(其他参数都是true)。

这其实是一种叫做"抽象解释"的程序分析方法

自定义数据结构

haskell中的数据结构类型定义与MoonBit的enum相仿,不过,由于CoreF是个用于演示惰性求值的简单玩具语言,它不能自定义数据类型,内置的数据结构只有惰性列表。

(defn take[n l](case l[(Nil) Nil][(Cons x xs)(if (le n 0) Nil (Cons x (take (sub n 1) xs)))]))

如上,通过case表达式可以对列表进行简单的模式匹配。

列表对应的图节点是NConstr(Int, List[Addr]), 它由两个部分组成:

  • 用于标记不同值构造子的标签,Nil对应的标签是0,Cons对应的标签是1

  • 用于存放子结构地址的列表,它的长度对应一个值构造子的参数数量(arity)

这个图节点的结构可以用来实现各种数据结构,但是coreF没做类型系统,为了演示方便只实现了惰性列表

我们需要增加两条指令Split和Pack,分别用于拆开列表和组装列表。

fn split(self : GState, n : Int) -> Unit {let addr = self.pop1()match self.heap[addr] {NConstr(_, addrs) => {// n == addrs.length()self.stack = addrs + self.stack}}
}fn pack(self : GState, t : Int, n : Int) -> Unit {let addrs = self.stack.take(n)// 此处假设参数数量一定足够self.stack = self.stack.drop(n)let addr = self.heap.alloc(NConstr(t, addrs))self.putStack(addr)
}

此外还需要一条指令CaseJump, 实现case表达式

fn casejump(self : GState, table : List[(Int, List[Instruction])]) -> Unit {let addr = self.pop1()match self.heap[addr] {NConstr(t, addrs) => {match lookupENV(table, t) {None => abort("casejump")Some(instrs) => { self.code = instrs + self.codeself.putStack(addr)}}}otherwise => abort("casejump(): addr = \(addr) node = \(otherwise)")}
}

在添加了以上指令后,还需修改compileCcompileE函数。case表达式需要所匹配的对象已经被求值到WHNF,所以只有compileE函数能编译它。

// compileECase(e, alts) => {compileE(e, env) + List::[CaseJump(compileAlts(alts, env))] }Constructor(0, 0) => {// NilList::[Pack(0, 0)]}App(App(Constructor(1, 2), x), xs) => {// Cons(x, xs)compileC(xs, env) + compileC(x, argOffset(1, env)) + List::[Pack(1, 2)]}// compileCApp(App(Constructor(1, 2), x), xs) => {// Cons(x, xs)compileC(xs, env) + compileC(x, argOffset(1, env)) + List::[Pack(1, 2)]}Constructor(0, 0) => {// NilList::[Pack(0, 0)]}

不过,此时有一个问题出现了,先前打印程序求值结果只需要处理简单的NNum节点,而NConstr节点是有子结构的,并且在列表本身被求值到WHNF时,列表的子结构多半还是待求值的NApp节点。我们需要增加一个Print指令,它会递归地进行求值并将结果写入GStateoutput组件中。

struct GState {output : Buffer......
}fn gprint(self : GState) -> Unit {let addr = self.pop1()match self.heap[addr] {NNum(n) => {self.output.write_string(n.to_string())self.output.write_char(' ')}NConstr(0, Nil) => self.output.write_string("Nil")NConstr(1, Cons(addr1, Cons(addr2, Nil))) => {// 需要强制对addr1和addr2进行求值,故先执行Eval指令self.code = List::[Instruction::Eval, Print, Eval, Print] + self.codeself.putStack(addr2)self.putStack(addr1)}}
}

最后将G-Machine的初始代码改成

let initialCode : List[Instruction] = List::[PushGlobal("main"), Eval, Print]

现在我们可以使用惰性列表编写一些经典的函数式程序,例如基于无穷流的fibonacci数列

(defn fibs[] (Cons 0 (Cons 1 (zipWith add fibs (tail fibs)))))

在引入数据结构之后,严格性分析也会变得更复杂。以惰性列表为例,关于它有多种求值模式

  • 完全严格(要求列表有限并且所有元素都不是bottom)
  • 完全惰性
  • 头严格(列表可以无限,但是里面的元素不可以有bottom)
  • 尾严格(列表必须有限,但是里面的元素可以有bottom)

甚至函数所处的上下文也会改变它对某个参数的求值模式(不能孤立地分析,需要跨函数),这种较为复杂的严格性分析一般采用射影分析(Projection Analysis)技术,相关文献:

  • Projections for Strictness Analysis

  • Static Analysis and Code Optimizations in Glasgow Haskell Compiler

  • Implementing Projection-based Strictness Analysis

  • Theory and Practice of Demand Analysis in Haskell

尾声

惰性求值这一技术可以减少运行时的重复运算,与此同时它也引入了一些新的问题。这些问题包括:

  • 臭名昭著的副作用顺序问题。

  • 冗余节点过多。一些根本不会共享的计算也要把结果放到堆上,这对于利用CPU的缓存机制是不利的。

惰性求值语言的代表haskell对于副作用顺序给出了一个毁誉参半的解决方案:Monad。该方案对急切求值的语言也有一定价值,但网络上关于它的教程往往在介绍此概念时过分强调其数学背景,对如何使用反而疏于讲解。笔者建议不必在这方面花费过多时间。

haskell的后继者Idris2(它已经不是一个惰性的语言了)除了保留Monad,还引入了另一种副作用处理机制:Algebraic Effect。

SPJ设计的Spineless G-Machine改进了冗余节点过多的问题,而作为其后继的STG统一了不同种类节点的数据布局。

除了抽象机器模型上的改进,GHC对haskell程序的优化还重度依赖于基于inline的优化和以射影分析为代表的严格性分析技术。

2004年,GHC的几位设计者发现以前这种参数入栈然后进入某个函数的调用模型(push enter)反而不如将责任交给调用者的eval apply模型,他们发表了一篇论文Making a Fast Curry: Push/Enter vs. Eval/Apply for Higher-order Languages。

2007年,Simon Marlow发现tagless设计中的跳转并执行代码对现代CPU的分支预测器性能影响很大。论文Faster laziness using dynamic pointer tagging中描述了几种解决方案。

惰性纯函数式语言展现出了很多别样的可能性,但对它的批评和反思也不少。不过,至少它是一种很有意思的技术!

Haskell系列往期文章:
8000字都是干货!教你如何用MoonBit实现Haskell求值语义
深入探索如何在 MoonBit 中实现 Haskell 求值语义(系列二)

这篇关于深入探索如何在 MoonBit 中实现 Haskell 求值语义(三)的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1058211

相关文章

【前端学习】AntV G6-08 深入图形与图形分组、自定义节点、节点动画(下)

【课程链接】 AntV G6:深入图形与图形分组、自定义节点、节点动画(下)_哔哩哔哩_bilibili 本章十吾老师讲解了一个复杂的自定义节点中,应该怎样去计算和绘制图形,如何给一个图形制作不间断的动画,以及在鼠标事件之后产生动画。(有点难,需要好好理解) <!DOCTYPE html><html><head><meta charset="UTF-8"><title>06

hdu1043(八数码问题,广搜 + hash(实现状态压缩) )

利用康拓展开将一个排列映射成一个自然数,然后就变成了普通的广搜题。 #include<iostream>#include<algorithm>#include<string>#include<stack>#include<queue>#include<map>#include<stdio.h>#include<stdlib.h>#include<ctype.h>#inclu

深入探索协同过滤:从原理到推荐模块案例

文章目录 前言一、协同过滤1. 基于用户的协同过滤(UserCF)2. 基于物品的协同过滤(ItemCF)3. 相似度计算方法 二、相似度计算方法1. 欧氏距离2. 皮尔逊相关系数3. 杰卡德相似系数4. 余弦相似度 三、推荐模块案例1.基于文章的协同过滤推荐功能2.基于用户的协同过滤推荐功能 前言     在信息过载的时代,推荐系统成为连接用户与内容的桥梁。本文聚焦于

【C++】_list常用方法解析及模拟实现

相信自己的力量,只要对自己始终保持信心,尽自己最大努力去完成任何事,就算事情最终结果是失败了,努力了也不留遗憾。💓💓💓 目录   ✨说在前面 🍋知识点一:什么是list? •🌰1.list的定义 •🌰2.list的基本特性 •🌰3.常用接口介绍 🍋知识点二:list常用接口 •🌰1.默认成员函数 🔥构造函数(⭐) 🔥析构函数 •🌰2.list对象

【Prometheus】PromQL向量匹配实现不同标签的向量数据进行运算

✨✨ 欢迎大家来到景天科技苑✨✨ 🎈🎈 养成好习惯,先赞后看哦~🎈🎈 🏆 作者简介:景天科技苑 🏆《头衔》:大厂架构师,华为云开发者社区专家博主,阿里云开发者社区专家博主,CSDN全栈领域优质创作者,掘金优秀博主,51CTO博客专家等。 🏆《博客》:Python全栈,前后端开发,小程序开发,人工智能,js逆向,App逆向,网络系统安全,数据分析,Django,fastapi

让树莓派智能语音助手实现定时提醒功能

最初的时候是想直接在rasa 的chatbot上实现,因为rasa本身是带有remindschedule模块的。不过经过一番折腾后,忽然发现,chatbot上实现的定时,语音助手不一定会有响应。因为,我目前语音助手的代码设置了长时间无应答会结束对话,这样一来,chatbot定时提醒的触发就不会被语音助手获悉。那怎么让语音助手也具有定时提醒功能呢? 我最后选择的方法是用threading.Time

Android实现任意版本设置默认的锁屏壁纸和桌面壁纸(两张壁纸可不一致)

客户有些需求需要设置默认壁纸和锁屏壁纸  在默认情况下 这两个壁纸是相同的  如果需要默认的锁屏壁纸和桌面壁纸不一样 需要额外修改 Android13实现 替换默认桌面壁纸: 将图片文件替换frameworks/base/core/res/res/drawable-nodpi/default_wallpaper.*  (注意不能是bmp格式) 替换默认锁屏壁纸: 将图片资源放入vendo

C#实战|大乐透选号器[6]:实现实时显示已选择的红蓝球数量

哈喽,你好啊,我是雷工。 关于大乐透选号器在前面已经记录了5篇笔记,这是第6篇; 接下来实现实时显示当前选中红球数量,蓝球数量; 以下为练习笔记。 01 效果演示 当选择和取消选择红球或蓝球时,在对应的位置显示实时已选择的红球、蓝球的数量; 02 标签名称 分别设置Label标签名称为:lblRedCount、lblBlueCount

Kubernetes PodSecurityPolicy:PSP能实现的5种主要安全策略

Kubernetes PodSecurityPolicy:PSP能实现的5种主要安全策略 1. 特权模式限制2. 宿主机资源隔离3. 用户和组管理4. 权限提升控制5. SELinux配置 💖The Begin💖点点关注,收藏不迷路💖 Kubernetes的PodSecurityPolicy(PSP)是一个关键的安全特性,它在Pod创建之前实施安全策略,确保P

【C++高阶】C++类型转换全攻略:深入理解并高效应用

📝个人主页🌹:Eternity._ ⏩收录专栏⏪:C++ “ 登神长阶 ” 🤡往期回顾🤡:C++ 智能指针 🌹🌹期待您的关注 🌹🌹 ❀C++的类型转换 📒1. C语言中的类型转换📚2. C++强制类型转换⛰️static_cast🌞reinterpret_cast⭐const_cast🍁dynamic_cast 📜3. C++强制类型转换的原因📝