从上周四开始的周末(1/7-1/10),是 Tubi 一年一度的 OSS-a-thon。所谓 OSS-a-thon,是我们为了回馈开源社区举办的 hackathon,参与者需要做和开源项目有关的项目 — 可以是对已有的开源项目进行改进,提交 PR,或者做新的项目,但需要开源。
我本来计划为 quenya 项目支持 GRPC backend,之前已经有了思路,但一直没时间做。后来觉得这事儿可能花不了四天时间;另外真要拿 quenya 出来 demo,又不太讲究,毕竟是一个已经做了一个多月的项目了。后来想想做点有意思但不一定那么特别有意义的事情吧,就选了一个相对冷门的方向:让 Elixir 更好地支持 data science。
之所以觉得这个方向不是特别有意义,是因为 Python 在 data science 上有非常完整的生态系统,其它通用语言(不是 Julia 或者 R 这样的专业语言)在这里争一席之地代价太大,而 Python 优雅而强大的表现力又很适合小白进入这个领域,进一步提升了其它语言的准入门槛。因此让 Elxir 这样一门小众的函数式编程语言去支持其本身并不擅长且没有积淀的 data science 方向,吃力不讨好且意义不大。
说句题外话。「意义」这个东西很多时候是我们这些成人失去孩童时的「想象力」和「好奇心」的罪魁祸首。因为工作中我们要 "make meaningful contribution",所以很难天马行空,大开大合;生活中我们要 "make meaningful life",所以守着边界,谨言慎行。所以如果不做 hackathon,「让 Elixir 更好支持 data science」这事儿我大概率不会去碰。
为什么说它有意思呢?作为 pandas / altair[1] / jupyter notebook 的一个中度用户,我很好奇:如果用已有的库攒一套类似的套件,会遇到多少沟沟坎坎?工作量究竟有多大?而我四天时间究竟能做出什么样的东西?为了探索这些问题,我抱着 "experience is what you get when you didn't get what you wanted" 的态度,轻装上阵。
如何在 Elixir 上「复刻」一个 pandas?
我第一个要解决的问题是做一个可以(或者至少有潜力)对标 pandas 的工具。这是一个浩大的工程,且不论 pandas 背后的整个生态(比如 numpy),光是 pandas 自己,就有 2000 贡献者,和接近 50万行代码。所以我唯一的选择是站在巨人的肩膀上,借助社区里已有的力量。前文讲过,Elixir 在 data science 生态圈几乎没有任何建树,所以我只能寄希望于在 rust 社区找一个合适的实现,然后为其封装 elixir 接口。
首先我把目光瞄准了 apache arrow[2] —— 一个用 C 撰写的高性能数据处理和分析的库。arrow 的很多功能和 pandas 重合,但更加偏底层。arrow 有很多语言的 bindig,包括 rust binding,但功能最完备的是 python 的 binding:pyarrow,可见 Python 在这个领域的地位。rust binding 功能虽然不像 pyarrow 那么完整,但也够用。在研究 arrow 的过程中,我发现了一个很年轻,但非常接近我的需求的 rust 库 polars[3] —— 其野心是成为效率更高,速度更快的 pandas。简单探索之后,我发现 polars 底层正是使用了 arrow 来构建 dataframe / series,然后提供了一个完整的 Pythong binding(python,又见 python)。在大致浏览了一下 polars 的文档后,我暗自欣喜,就是它了!
接下来就是 Elixir 代码和 Rust 代码的交会对接。
在 Elixir 上使用 rust,rustler[4] 是最好的工具。我之前在 Tubi 举办的 Elixir meetup 上做过一个 tech talk,介绍过 rustler,感兴趣的同学可以看我的 github repo: tyrchen/elixir-meet-rust。
于是 hackathon 开始后,我便开工撰写 ex_polars [5] —— polars 的 elixir binding。这个库包含 Rust 代码和 Elixir 代码,Rust 代码负责交会对接 —— 桥接 polars 和 Elixir;Elixir 代码负责貌美如花 —— 提供优雅的接口让使用者用得够爽。
做过某个语言到另一个语言的桥接的同学都会有这样的体验:代码本身逻辑并不复杂,主要其实就是接口的类型转换,比如把 rust struct 翻译成 elixir struct,或者反之。所以这是个琐碎的苦差事,需要的耐心和毅力。四天下来,我写了超过 2000 行 rust 代码。听着挺唬人的成就,其实大部分代码就是茴香豆的「茴」字的四种,哦不,四百种写法。
我是第一次写上千行代码,却没有写一行测试(实在是时间太紧),心里还不太打鼓。你看了代码就知道:
代码语言:javascript复制#[rustler::nif]
pub fn df_add(data: ExDataFrame, s: ExSeries) -> Result<ExDataFrame, ExPolarsError> {
df_read!(data, df, {
let new_df = (&*df &s.inner.0)?;
Ok(ExDataFrame::new(new_df))
})
}
这是 dataframe(可以类比为矩阵)和一个 series(可以类比为向量)相加的代码,是不是很没有技术含量?
是,也不是。
在 elixir 和 rust 间传递的数据结构就需要一番考量。我们知道,一个 dataframe 往往都很大,如果封装起来在 elixir 和 rust 间来回拷贝着传,那效率低到马里亚纳海沟里去了。所以为了效率,两边要传引用。而 dataframe 是可以修改的数据结构(如果调用时传入 inplace=True
),这就意味着 Elixir 到 Rust 侧的传递需要 RwLock Arc
,而为了在 Elixir 侧能够很好地 inspect 这个数据,我需要一个两边都方便传递的,包含 dataframe 引用的数据结构,于是就有了这样一个结构:
pub struct ExDataFrameRef(pub RwLock<DataFrame>);
#[derive(NifStruct)]
#[module = "ExPolars.DataFrame"]
pub struct ExDataFrame {
pub inner: ResourceArc<ExDataFrameRef>,
}
这样一个结构,两边来回拷贝,也就几十个字节,无伤大雅,但满足了我能够很方便地在 elixir 侧进行 inspect 的需求(就好像完整的数据在 elixir 侧一样)。我只需实现 Inspect
接口即可:
defimpl Inspect, for: ExPolars.DataFrame do
alias ExPolars.Native
def inspect(data, _opts) do
case Native.df_as_str(data) do
{:ok, s} -> s
_ -> "Cannot output dataframe"
end
end
end
于是,当一个 dataframe 被读取出来时,在 Elixir 里,其展现是非常友好的,和 pandas 一个水准:
Well done, buddy。有什么比 elixir 和 rust 两边写了几个函数就得到了这样一个沁人心脾的结果更美妙的呢?嗯,完美的开局意味着美好的结局,我对自己说。
第一次撞墙:RAII
作为一个很懒惰的程序员,在不断遇到重复的代码 pattern 时,想到的第一件事就是 DRY (Don't Repeat Yourself)。很快,我发现在 Rust 侧要访问 dataframe,自己总要写这样的代码:
代码语言:javascript复制match data.inner.0.read() {
Ok(df) => deal_with_df,
Err(_) => Err(ExPolarsError.Internal(...))
}
没办法,因为在 rust 侧拿到引用后,需要取读锁(或者写锁),然后才能做相应的操作。于是我想到了重构。可能是之前完美的开局让我有些飘飘然,我想也没想大手一挥,把上述代码封装到一个函数里:
代码语言:javascript复制def get_reader(*self) -> DataFrame {
match data.inner.0.read() {
Ok(df) => &*df,
Err(_) => Err(ExPolarsError.Internal(...))
}
}
然后喜滋滋就这么一个函数一个函数地写下去,直到。。。Rust 编译器教我做人。
我才意识到读写锁返回的是一个特殊的 Result:Result<RwLockReadGuard<T>, PoisonError<RwLockReadGuard<T>>>
,它是 RAII 结构 [6](有关 RAII 的介绍请自行 wikipedia,否则这篇文章不用写别的了),所以我必须在同一个上下文中处理(不同的上下文意味着并发下的重入问题,rust 编译器帮我们杜绝了这种情况导致的死锁)。这是我遇到的第一个 brick wall。我的「人生导师」Randy Pausch 说:
我不想放弃我 DRY 上的努力,但封装函数此路不同,怎么办?
还好,rust 有 macros。于是,几经探索后,我写下了这段代码:
代码语言:javascript复制macro_rules! df_read {
($data: ident, $df: ident, $body: block) => {
match $data.inner.0.read() {
Ok($df) => $body,
Err(_) => Err(ExPolarsError::Internal(...)),
}
};
}
这就是本文开头那段示范代码中函数 df_add
里使用了奇怪的 df_read!
。这让我每个函数少些很多重复的代码,最大程度让 Rust 编译器满意,并且使我的代码足够 DRY。
我对自己对 DRY 的追求十分满意。松本行弘(Matz)先生,谢谢十年前你对我 DRY 的引导。
第二次撞墙:双向调用
我是边写边翻看 Polars Python 的接口 —— 纵然我的 ExPolars 不能和 Pandas 争朝夕,和 PyPolars 总可以拼上一下吧。
当我写到 groupby_apply
时,我发现:额错了,额真滴错了。
Elixir 在 data science 领域是二等公民,这我认了,特么 rust 对其它语言的支持,elixir (目前)也是个二等公民?唉。我真是 too simple, sometimes naive。
groupby_apply
顾名思义,就是对着一个 dataframe 使用一个 lambda function 做 groupby 的操作。人家 rust 和 python 之间的互操作,是你来我往,双向互动,python 可以扔一个函数给 rust,在 rust 上下文里执行 python 代码(rust 对 python 的支持从 Python::acquire_gil
到 PyModule::import
都给你安排好了,就问你服不服);而 rust 和 elixir 间的互操作,是单向的,elixir 可以调 rust 的代码,但无法扔一个函数给 rust,在 rust 上下文执行 elixir 代码。
嗯,更准确地说,rustler 及 erlang 运行时没有提供一个简单可操作的方式让 erlang 运行时可以运行在 rust 上下文里。叹气。
好吧,python 运行时并不复杂,主要是一个 GIL,其数据结构的 memory layout 和 C/rust 一致,所以把整个 python 运行时封装成 rust code,把 rust 数据结构封装成 PyObject,也并不困难。erlang OTP 就相当复杂了,毕竟为了运行一个函数,你要起一个完整的 BEAM OTP kernel code server 一堆 applications,也太费劲了(我猜测,毕竟没做过)。也许只有等某个用 Rust 写就的 BEAM 稳定可用,这个问题才有最佳解。可惜的是,前两年窜出来的一票用 Rust 写的 BEAM 项目,基本都停止了更新。
当然,这个问题可以 workaround。最简单的方法是 groupby_apply
完全在 elixir 侧实现,lambda function 在 Elixir 上下文执行:
list_series = Enum.map(df.column_names, fn name ->
series = DF.column(df, name)
array = S.to_list(series)
array = lambda(array)
S.new(S.name(series), array)
end
DF.from(list_series)
问题是这个方法不够通用,只能 case by case 地写。
还有一个方法是可以为 lambda function 生成 gen_server 或者 tcp_server,rust 代码需要执行 lambda 函数时,向 gen_server / tcp_server 发送消息(和数据),elixir 侧做完再返回数据供 rust 侧继续执行。这样理论上可行,算是上述方案的一个更加通用的版本。
为了这么一个并不那么重要的函数做这么多工作,太耗时间,所以我放了一个含情脉脉的 TODO (tchen)
,待有空回来再做。
永远不要相信程序员的 TODO。- 来自某代码维护者
如果一个程序员竟然会真的完成他的 TODO 或者 FIXME,那就嫁了吧。- 来自某妹子
第三次撞墙:rust lib export
写完 dataframe 相关的 rust 代码后,我又快马加鞭撰写 series 相关的代码。刚把骨架搭起来,写了两个函数,就发现 rust 编译器又报错了。原因是 rustler::init!()
只能运行一次。rustler::init!()
是用来声明哪些函数要 export 到 elixir 去的一个宏。因为写了两个数据结构(dataframe 和 series)的处理方法,于是我便自然而然想让每个结构都对外 export 成一个 Elixir module,如:Elixir.ExPolars.DataFrame
和 Elixir.ExPolars.Series
。这是个非常合理的需求,然而 rustler 并不允许我这么做。心碎之余,我仔细思考了一下,觉得这也是有道理的,毕竟 Elixir module 需要在加载到 code server 时去加载编译好的 so 库,如果多个 module 都去加载,(可能)会造成内存浪费。
既然这是个硬性的限制,那么我就只好退而求其次,为所有 dataframe 的函数加上 df_
前缀,所有 series 的函数加上 s_
前缀,并将他们一起 export 给 Elixir.ExPolars.Native
,然后再写两个 module Elixir.ExPolars.DataFrame
和 Elixir.ExPolars.Series
分别 proxy Elixir.ExPolars.Native
中的代码。
问题解决!
第四次撞墙:macro_rules!
为了 DRY,我也算是无所不用其极,能写成 macro_rules
的代码我都用宏来减少需要手工撰写的重复代码。比如两个 series 的比较,就有:等于,不等于,大于,大于等于,小于,小于等于这些方法。每个函数的代码都大同小异,此时不用宏,更待何时?
macro_rules! impl_cmp {
($name:ident, $type:ty, $operand:ident) => {
#[rustler::nif] // <- this line will have issue
pub fn $name(data: ExSeries, rhs: $type) -> Result<ExSeries, ExPolarsError> {
let s = &data.inner.0;
Ok(ExSeries::new(s.$operand(rhs).into_series()))
}
};
}
impl_cmp!(s_eq_u8, u8, eq);
然而,rust 编译器又一次让我撞墙。编译器给出的错误信息不够直白,说的净是那些什么 TokenStream 啦,什么 Group 啦,之乎者也让人看不懂的错误。因为 #[rustler::nif]
是个 procedure macro,所以我高度怀疑:
- rust 编译器在某种情况下不能很好地处理
macro_rules
和 procedure macro 共存的边缘问题 #[rustler::nif]
写得不够好,导致 1
如果说我的 rust 有初三的水平,那么我对 procedure macro 的应用和理解还在学前班。指望我自己短时间能够修改 #[rustler::nif]
绕过这个问题不大现实。
所以,我唯有修改我写的 impl_cmp
宏,看看怎么绕过去。几经试探后,我发现,如果 $type:ty
被用在函数的参数里,会出错,用在返回值里,不会出错。所以,我只好放弃了一个宏同时替换类型和操作符的思路,改为一个宏只负责替换操作符:
macro_rules! impl_cmp_u8 {
($name:ident, $operand:ident) => {
#[rustler::nif]
pub fn $name(data: ExSeries, rhs: u8) -> Result<ExSeries, ExPolarsError> {
let s = &data.inner.0;
Ok(ExSeries::new(s.$operand(rhs).into_series()))
}
};
}
impl_cmp_u8!(s_eq_u8, eq);
这样的问题是代码不够那么 DRY,但至少可以工作。
那位问了,为何不用泛型(generics)呢?好问题!因为#[rustler::nif]
需要把输入输出类型和 elixir 的类型进行互换,所以这里需要确定的类型,而无法使用模板。
完成 ExPolars
当我最终机械地完成 dataframe 和 series 里三百多个函数在 Rust 侧和 Elixir 侧的封装后,感觉胃里有东西要往出呕。我这辈子也没写过这么多无趣的代码。什么 TMD 叫搬砖,这 TMD 就叫 TMD 搬砖。那一刻,我对自己所有用过的高性能类库的作者们肃然起敬,因为言及高性能,背后必然是 C/C /Rust,然后必然要做大量的工作把写好的功能封装一下供 Python / Javascript / Elixir 这样的高级语言使用。这些作者们在享受完正儿八经写代码的乐趣后,在写封装代码时必然也会有像我此时的呕吐感。
老婆说这两天的状态就像《心灵奇旅》里的那些深度沉迷,口中喃喃 "make the trade, make the trade" 的证券交易员一样,除了吃饭睡觉,娃也不管了,就是 write the code,write the code 。。。
呕吐感之后,就是轻松。
我在 Jupyter(使用 IElixir kernel)上随意玩着我刚刚完工的玩具,心里默默地和 pandas 对比。不得不说,Python 的语法真的非常适合 data science 的场景。爱死了那些 magic functions,如 __setitem__
,__getitem__
等,使得 dataframe 的操作就如同操作 dict 一样从心所欲。比如 pandas 里这样一个简单直白的操作:df["cap"] = df["price"] * df["volume"]
,在 ExPolars 里需要写成:DF.set(df, "cap", DF.column(df, "price") * DF.column(df, "volume")
,有股说不出来的难受劲。我在我随着 repo 一起提供的 notebook 里也承认了这一点:
不管怎样,看着自己一手打造的工具在 Jupyter notebook 上跑了起来,还是很有成就感的。接下来,就是数据可视化了。没有好的可视化解决方案的 data science 工具不是个好工具。于是,我把目光投向了 vega-lite[7],一个我个人非常喜欢的声明式(declarative)的可视化工具。我没有亲自写过 vega-lite 的代码,只是在使用 Python 的一个可视化工具 Altair 时大致了解过 vega-lite。hackathon 剩下大概一天左右的时间,我边看 vega-lite 的代码样例,边用 Elixir 简单地封装 vega-lite,让 ExPolars 加载出来的 dataframe 可以被很方便地可视化,就像下面这样:
封装 vega-lite 的过程很轻松,是一种享受,我为这个项目取名为 deneb。为了把生成的图表展示在 jupyter notebook 上, 我花费了不少时间,踩了一个很大的坑。
欲知后事如何,请听下回分解!
参考资料
我的 hackathon 项目:
- tyrchen/ex_polars
- tyrchen/deneb
感兴趣的同学可以关注。本文中提到的其它项目:
[1] altair: github.com/altair-viz/altair
[2] arrow: arrow.apache.org
[3] polars: github.com/ritchie46/polars
[4] rustler: github.com/rusterlium/rustler
[5] ex_polars: github.com/tyrchen/ex_polars
[6] RAII: doc.rust-lang.org/std/sync
[7] vega-lite: vega.github.io/vega-lite
贤者时刻
写搬砖性质的代码非常让人痛苦,可这痛苦比起练琴,那简直不是个事。一首 Nocturne,小宝学习了大半年,她的老师,一位俄罗斯老太太还是在不断地给她挑毛病。每当我在三楼写代码时,透过开启了 noice cancellation 的 Airpod Pro 耳塞还能听到一楼小宝练琴时的痛苦撕号,我就觉得自己遇到的困难都不是个事儿。在我 hackathon 的间歇,小宝终于弹出了非常不错的一个版本:
当老太太听完这个版本后,先是大大夸赞了小宝一番,说她有无与伦比的天赋,随后话锋一转挑出了十几二十处还可以表达地更好的地方。肖邦真是个老贼,在这样一首看似并不复杂的 Nocturne 里不知道埋下了多少深坑,揉进了多少感情。没办法,小宝只能继续痛苦地练下去。
于是我便拿《朗读者》某一期谈论《痛》这个主题时董卿说的一段话和小宝共勉(其实也是给我打强心剂):
艰难困苦,玉汝于成。痛是文学作品当中绕不开的一个主题,因为它本身就是人生的一堂必修课。穿越痛苦的方法是经历它,吸收它,探索它,理解它到底意味着什么?倒也不必始终将痛拒之于门外,唯一要做的是不要忘记给自己点燃一盏名叫希望的灯火。就像普希金在诗中写到的“灾难的姐妹,希望永远会唤醒勇气和欢乐。”