最近写个Presto的UDF,发现点比较有趣的地方.
因为功能上需要运行时访问外部数据,做个类似缓存的读写动作.
所以形式上来说,这是一个带状态的函数.
在SparkSQL里的话,这个处理比较简单.
由于整个execution pipeline是基于序列化的,所以只要能够提供一个某种程度上determinist的初始状态,那么各个executor是可以有一致的表现的.
当然,这个有一些前提.
主要是class版本的一致性.
这个主要是由于kyro本身的问题.
但总得来说是比较straightforward的.
但是presto不太一样.
它采取了一些可能从设计上来说,比较学院派或者说不是那么实用主义的设计.
从架构上来说,它的udf主要通过一种plugin机制扩展.
而为了比较好地.或者说便于udf的作者提供更flexible的实现,所以采用了一个独立的plug classloader的方式加载对应的实现.
这个从工程上来说,也是一个可能容易被选择的方案.
因为弱化了实现的库版本依赖的约束,允许plugin作者选择自己prefer的各种其他库.
这里它为了比较好地解决不同classloader同名类不兼容的问题,主要也是plugin spi类的可cast问题,在plugin classloader里面做了个白名单机制.
是的SPI的类从同一个classloader加载,避免隔离机制造成的互相不兼容.
不过这里形式上也提供了一种调用主classloader的实现的一些方式.
毕竟已知的白名单类可以得到app classloader,自然也就有办法使用到所有的类.
不过这算classloader这种sandbox机制的某种特性吧.
毕竟形式上来说,这也是JVM的claasloader sanbox对立统一的一面.
而且整个udf runtime和execution pipeline的执行某种程度也是依靠这种leakage来实现的.
因为它不像SparkSQL是依靠序列化传递函数实现,而是靠比较轻量的某种执行计划描述在个节点节点重构调用链的.
所以形式上来说,可能各个节点运行的版本并不严格一致.
因为jar包可能不一样.
不过这在实际场景下可能不是一个大问题.
尤其如果是用容器方式运行的话.
这里的主要问题在于它对udf函数的初始化处理.
大体上来说,是比较标准的依赖注入的思路.
扫描类的annotation来生产udf的描述信息和运行时绑定方式.
这里比较tricky的是对udf的绑定是通过method handler实现的.
这个大概率可能是一种基于性能借口的炫技.
因为对于一个generic的udf来说,入参数量和返回值是不确定的.
像SparkSQL就索性采用了一个透明的类Object/Any方式.
好处是SPI接口简单.
坏处也显而易见,不能简单地知道入参出参的类型.
Presto形式上来说也可以采用这样的方式.
而且实际上来说,如果采用这种方式的话,可能更有利于vectorize.
毕竟本身就内部pipeline的传递的page/block就是某种batch data.
但显然作者没有采用这种实用主义的手法.
而是用了method handler以便能够在udf的声明上就清晰函数的定义.
把heavy lifting的事情放在method handler的参数绑定上.
甚至炫技的地方还不单在这种类curry的functional化上.
甚至还允许函数声明根据参数类型做specialize,一定程度上做着类似template specialized的事情.
对特定类型的参数可以提供统一但优化的实现和声明.
这个炫技本身倒没什么.
只不过从实际实现上来说,它引入了一个隐性约束.
就是这个函数的implementaion部分必须是static的.
因为这样才可能在早期绑定确定的method handler.
虽然看实现上也支持非static method的绑定.
但对于hosting class的constructor有一定的限制.
而且本质上来说,这个hosting instance也是once bounded的.
作用上就是一个static的singleton.
所以没办法从这个角度去让某个udf调用带有状态.
于是要带状态的话,一种思路是把状态相关的部分作为参数inject到udf的入参里.
这个的问题主要是在explain语句的时候,会有一些可能或干扰或敏感或意义不明的部分让人confuse,或者说知道了不必知道的实现细节.
当然,这个通过override某些函数是可以redacted的.
但是总的来说,不是很便利.
另外一点就是late binding的问题.
有时候udf入参的一部分并不需要立即evaluate,而是需要在udf实现里根据情况决定是否做eval.
这个在SparkSQL因为expression是可以自定义哪些需要eval的.
不管是eval模式还是codegen模式,自主性都比较flexible.
而presto的因为前面method handler的那些magic,基本上generate出来的bytecode都是实际eval出来的结果再入参的.
也就是说,在这方面的可控性并不如SparkSQL.
如果将这些late binding需求的部分作为原始string传入,再再udf里编译的话,有几个问题.
一个是这个compile流程不算友好.
如果bytecode模式的话,需要处理多个class loader的问题.
因为前面也说了,plugin本身的class是在一个半isolated的class loader.
而bytecode generate因为使用了method handler这个builtin class,所以形式上可以不管实现,在一个独立的mini classloader就可以完成code gen.
而卒后执行的时候又是在main classloader里执行.
所以在运行时,或者说函数的调用逻辑里需要毕竟明确的知道会触发class loading的点和确保bind了正确的classloader.
而如果是interpreter模式的话同样有类似的问题.
并且除了这个问题之外,还有怎么构造compiler/interpreter也是跟问题.
因为整个presto是按照guice的inejctor构造的.
而plugin的接口和loading方式又缺乏引入injector从而获得相关依赖服务构造对应compiler/interpretor的方式,所以实际上也不太可行.
即使是强行构造了一些等价接口的话,在覆盖率上也是有所差异的.
这使得即使构造成功,可能运行时的表现也不尽如人意.
再就是即使构造成功,处于性能考虑,如何对整个compile结果缓存也是个问题.
因为前面所说的method invoke是无状态的.
不过因为本身表达式是string形态,所以缓存这方面倒相对来说更直接一点.
而如果不走这种compile/interpretor方式的话,presto倒是提供了一种间接或者说直接late binding的方式.
那就是presto sql的lambda expression.
这个倒是解决了按需evaluate的问题.
并且不需要复杂的compile/interpreter,甚至于都不需要做method handler的缓存.
意味本身就已经是bindable了的,在函数声明里就是个通用的明确的functional interface.
但这里也回到了缓存状态的问题上.
针对一个udf的调用,形式上是根据某些特定的入参生成一个确定的cache key以帮助查找的.
但是如果同时有late binding的需求的话,那么因为functional interface本身不太cache sensitive.
也就说,很难说两个functional interface是否具有相同的逻辑,从而增加了缓存的难度.
而这里因为前面说的bytecode codegen的classloader是个不关心/aware plugin classloader的.
所以实际上它接收的functional interface只能是jdk builtin的interface.
无法通过扩展的方式,使得运行时能从这个入参里得到一些比较显著的信息.
能想到的方式只能是walkaroud地再inject一个足够distinguish的比如原始expr的string参数,同时想办法在explain等场景里给它屏蔽/不显示干扰.
总的来说,核心问题可能还是在于Presto整体设计有些学院派.
不像SparkSQL更多的是秉持某种能用就行.
虽然比较讽刺的是Presto本身是Facebook这种喊出move fast break things的工程实用主义的公司搞出来的.
不过想想自己也是.
曾几何时倒也是看不太起Spark+Scala这种到处又不是不能用哲学组合的产品.
现在却是走到了理解成为的阶段.
没有评论:
发表评论