Template Haskell和交叉编译的故事

2020-12-06 14:57:43

模板Haskell(TH)是一种广泛使用但有争议的语言扩展。您可能已在自己的代码中使用了它;只需一行拼接代码,您就可以轻松完成诸如派生实例和嵌入文件之类的任务。您可能还听说过人们不喜欢它的原因:它减慢了编译速度,破坏了封装,在编译时任意IO都是有风险的,等等。

但是鲜为人知的是,模板Haskell也使得与GHC的交叉编译更加困难。在本文中,我们将说明这是一个挑战的原因,社区开发的一些现有解决方案,尤其是Asterius如何解决此问题。

从概念上讲,模板Haskell是在编译时生成Haskell AST的原则方法,如下面的简化示例所示:

{-#LANGUAGE TemplateHaskell#-} import Data.Char import Language.Haskell.TH.Syntax import System.Process gitRev :: String gitRev = $(do rev<-runIO $ filter isHexDigit< $> readProcess&#34 ; git" [&rev-parse"," HEAD"]"" liftString rev)

假设我们要定义一个gitRev字符串,该字符串表示项目存储库中的当前gitrevision。这可以使用表达式splice来完成:它使用$(...)语法编写,并且$()中的内容是Q Exp类型的表达式,表示返回Exp值的编译时计算,即,在这种情况下,当前的git修订版为stringliteral。

拼接代码位于Q monad中,后者负责管理TemplateHaskell的上下文并提供一组丰富的接口。在Q内部,我们可以查询有关数据类型或函数的信息,分配新的标识符等。也可以在Q内部运行任意IOaction。在这里,我们运行git rev-parse HEAD以获得git版本,然后将其返回。当GHC编译此模块时,将拼接替换为字符串文字,然后继续编译。

因此,乍一看,Template Haskell就是要在编译时运行用户代码,这会出错吗?对于大多数开发人员来说,这一切都正确,他们可以在运行GHC的相同平台上进行编译,但是当您尝试进行交叉编译时会遇到麻烦……

假设我们要为Android手机或RaspberryPi编写Haskell应用。可以在其上引导本地GHC版本并使用它来编译内容,但是鉴于这些计算机的硬件资源有限,因此在适当的x64构建服务器上运行GHC并为这些ARM设备发出代码是更明智的选择。这样做时,我们正在执行交叉编译。一些术语:

构建平台是我们编译GHC的地方。为简单起见,我们假定为build = host,并且从现在开始仅使用主机术语。

目标平台是我们运行已编译的Haskell应用程序的地方。当host = target时,GHC是本地GHC,否则为交叉GHC。

对于本地GHC,Template Haskell没问题,因为GHC可以像本地动态库一样链接并运行其发出的代码。但这并不是跨GHC的即席锻炼。

多年来,人们提出了不同的方法来解决Template Haskell的交叉编译问题,每种方法都有其自身的优势。更多详细信息,请参见后面的部分。

如果我们无法运行发出的代码,那我们根本不运行它,而在没有TH支持的情况下继续使用交叉GHC呢?我们将预处理GHC交叉输入代码,Template Haskell扩展程序的条带用法,并将所有TH接头替换为扩展代码。扩展接头的方法是…使用nativeGHC进行编译!

GHC标志-ddump-splices会转储扩展的接头代码。不幸的是,转储输出具有额外的文本修饰,并且不是正确的Haskellsource代码,因此使用转储需要更多的工作。下面列出了接头转储方法的已知实现:

EvilSplicer使用基于Parsec的解析器来处理转储,以供以后使用交叉GHC。直到2018年下半年为止,它一直在git-annex项目中使用。

ZeroTH是一种功能类似的工具,它包括CLI和Cabal相关的帮助程序功能。

reflex-platform使用修补的本机GHC,它会将扩展的接头作为正确的Haskell源代码转储,并馈入GHCJS。

与gcc或clang可以通过简单地添加相关的CLI标志来为其他平台发出代码不同,GHC安装只能为在其构建时配置的单个目标平台发出代码。因此,必须在孤立的地方管理两种不同的GHC安装。

本机/跨GHC必须具有相同的版本并处理相同的构建计划,以最大程度地减少发出错误代码的机会。假设软件包foo包含使用软件包bar的THsplice,如果本机/跨GHC看到bar的不同版本(甚至相同版本但具有不同的构建计划),则拼接行为可能会有所不同,扩展为可能由交叉GHC静默消耗的错误代码。

考虑到所需黑客的复杂性以及GHC / Cabal缺乏交叉编译支持,通常使用外部构建系统(例如Nix)来封装此机制。

除了保存扩展接头的转储之外,还有另一种解决方案,仅在主机平台上运行TH接头代码:同一GHC总是在一次调用中将所有内容都编译为主机/目标代码!运行TH时,我们可以像本地GHC一样加载主机代码。这需要对GHC行为进行一些自定义,并且仅适用于基于GHC API的第三方编译器。实际上,GHCJS最早使用这种方法。

在主机平台上运行TH只能用于纯剪接,只能进行诸如验证信息和生成AST之类的操作。它对于读取文件,生成进程或射击导弹的副作用拼接也应该能很好地工作,因为拼接行为应与我们使用先验GHC编译东西时相同。

但这是故事的结局吗?还没。这是一个迫在眉睫的问题:尽管我们尽了最大努力,但当初/交叉GHC可能不会消耗相同的Haskell来源。

Haskell模块可能将CPP扩展与特定于目标的宏一起使用,因此,当针对不同的目标进行编译时,会看到不同的顶级定义。

阴谋集团的文件也可能会检查实施/平台/等,并最终以GHC消耗的不同标志甚至不同模块结尾。

上面的问题可能会触发编译时错误。还有一个甚至更隐秘的问题,可能导致生成错误的代码而不是崩溃:主机/目标的架构差异,例如字长或字节序,例如,TH接头可以使用sizeOf(undefined :: Int),在32位目标平台上为4,如果主机平台为64位,则THsplice会看到8,潜入发出的代码而没有任何警告。

如前所述,香草GHC只能链接和运行主机代码,可以教GHC链接和运行目标代码吗?答案是肯定的。支持运行非本机代码的关键是RPC(远程过程调用)。 GHC需要调用目标代码以获得拼接扩展结果;目标代码需要调用GHC进行验证。这些调用是通过在GHC和加载的接头之间交换序列化消息来实现的。由于Q monad中允许使用一组固定的操作(作为Quasi类的方法),因此这些操作和结果可以被编码为可序列化的Message数据类型。

这种运行TH代码的RPC方法是在外部解释器功能中标准化的。运行TH时,GHC启动一个外部进程调用iserv,将消息传递到iserv并告诉它到负载归档,对象等以及链接代码。接头开始在iserv中运行后,iserv可能会将查询发送回GHC并获得结果。最后,拼接扩展结果被发送回GHC。

外部解释器打开了使用各种仿真器的可能性(例如Windows的wine,js / wasm的node或奇异平台的qemu)运行TH的目标代码。 GHC本身不需要关心代码如何在iserv中实际链接和运行,只要目标特定的iserv可以正确处理消息,TH就可以工作。

这种方法是由GHCJS率先提出的,后来以7.10的价格成为上游GHC。除GHCJS之外,已知用户包括:

GHC本身,甚至在本地GHC中!但是为什么要打扰呢?好吧,假设我们正在使用TH编译配置文件库。由于概要分析代码遵循不同的运行时约定并与概要分析的运行时链接,因此在早期,需要概要分析的GHC可执行文件。现在,我们可以简单地使用配置文件化的iservexecutable,并避免GHC中的额外分析开销。

Mobile Haskell是面向ARM的GHC发行版,它们使用Android / iOS仿真器来设置拼接运行时环境。 GHC通过管道与iserv-proxy进程对话,而iserv-proxy只是通过套接字将消息中继到仿真器中的真实iserv程序。

与在主机平台上运行TH相比,在目标平台上运行TH有一些好处:

减少黑客攻击,提高标准化程度。尽管上游GHC不太可能包含所有有趣目标平台的iserv实现,但开发人员可以根据需要推出自己的平台。

更简单,因为不再需要通过nix打包大量的hack,并且可以与vanilla cabal / stack一起使用。

宣布跨编译TH现已解决,这很诱人!事实并非如此。还记得TH如何启用运行任意IO接头吗?它在一些流行的软件包中使用,例如gitrev用于获取gitrevision,而文件嵌入用于嵌入文件。对于本地GHC,IOactions可以完全访问主机系统:其文件系统,外部工具等。但是对于跨GHC,IO操作可能在没有这些便利的沙箱中运行,因此这些程序包及其相关程序将无法编译!

TH接头中仍可能存在宿主特定的副作用,但需要逐案分析和修补。我们可以直接将someOperation添加到Quasi类的方法中,而不是runIO someOperation,然后将包打补丁以使用它。当接头运行时,iserv会简单地向GHC发送SomeOperation消息,GHC可以在主机上运行它并将序列化的结果传递回去。 MobileHaskell使用此文件来支持文件嵌入之类的程序包。

Asterius使用外部解释器方法来支持Template Haskell。编译的WebAssembly代码和JavaScript运行时的执行是在节点进程中完成的。由于节点沿编译器在同一台计算机上运行,​​因此它可以访问与本地GHC相同的资源,因此,只要Asterius节点运行时中支持某项操作,它将在TH接头中工作。给出已知编译包列表,到目前为止,这已经很好地工作了。

但是,当前的Asterius TH实现有一个限制:由于缺少真正的动态链接器/运行时,因此不会在同一模块中的不同接头上分散状态。为什么有人要这样做?一个示例是在所有接头上重用昂贵的资源,无论是进程句柄,网络套接字还是其他任何东西:

导入Language.Haskell.TH.Syntax数据资源newResource :: IO资源freeResource ::资源-> IO()useResource :: Q Resource useResource = do m<-Just r的getQ case m->纯r _->做r<-runIO newResource addModFinalizer $ runIO $ freeResource r putQ r纯r

在上面的示例中,我们有newResource / freeResource用于分配/释放昂贵的资源。然后,我们可以实现useResource,它尝试从TH会话中获取资源,并初始化一个不存在的资源。扩展同一模块中的所有接头后,将运行已注册的终结器。

放心,跨剪接状态持久性在实践中很少见。因此,对于典型的TH场景,我们当前的实现应该足够了。

对于大多数Haskeller来说,交叉编译并不是日常用例,因此他们可能没有意识到不能将TH之类的功能视为交叉设置。 我们希望上面的文字可以帮助我们提高对社区的认识。 您可以三思而后行,然后再将袖子卷起来,并通过runIO,Plugins或自定义Setup.hs接触TH等内容,以改善情况。 如果GHC面向另一个平台并配置了其他工具链,是否可以工作? 即使最终没有进行实际的测试,这种想法也有可能避免使您的项目的未来用户感到沮丧:) 对于感兴趣的读者,我们还建议您查看尚未完成的GHC阶段卫生提案,其中包括一些质量讨论。