Haskell FFI 进行 C 语言互操作的成本是多少?

31

如果我想调用多个C函数,每个函数都依赖于前一个函数的结果,那么创建一个包装器C函数来处理这三个调用是否更好?与使用Haskell FFI而不转换类型相比,它会花费相同的代价吗?

假设我有以下Haskell代码:

foo :: CInt -> IO CInt
foo x = do
  a <- cfA x
  b <- cfB a
  c <- cfC c
  return c

每个函数cf*都是一个C调用。

从性能的角度来看,创建一个单独的C函数如cfABC,然后在Haskell中只进行一次外部调用会更好吗?

int cfABC(int x) {
   int a, b, c;
   a = cfA(x);
   b = cfB(a);
   c = cfC(b);
   return c;
}

Haskell 代码:

foo :: CInt -> IO CInt
foo x = do
  c <- cfABC x
  return c

如何测量从Haskell调用C的性能开销?不是C函数本身的开销,而是从Haskell到C再返回时所产生的“上下文切换”的开销。


1
我不是很确定,但我发现这篇博客文章很有启发性。如果我理解正确的话,foreign ccall unsafe(其中unsafe是关键)基本上和内联C函数调用一样便宜。 然而,在使用unsafe时必须非常小心,而安全变体(foreign ccall)成本更高并涉及锁定。 - gspr
你可能认为任何差异都会被编译器消除... - Colin Woodbury
@fosskers:你是什么意思? - gspr
6
@ThiagoNegri:我做了一些简单的(非标准)基准测试,比较了“foreign ccall”和“foreign ccall unsafe”。我有一个C函数,给定双精度参数 double x,返回 sin(x)*sin(x)*cos(x)/2.0。我使用GCC 4.7.2和-O2编译它。基准测试将其用100000000个不同的参数从0到pi / 2调用,并汇总结果。使用“foreign ccall”大约需要9.6秒,而使用“foreign ccall unsafe”需要4.6秒。从实际的C程序中调用它所需的运行时间为4.4-4.5秒。这至少可以让你有一个想法。Haskell代码是使用GHC 7.4.2编译的。 - gspr
1
@gspr:别理我说的话。我对FFI的了解不够。 - Colin Woodbury
显示剩余2条评论
2个回答

20
答案:这个答案主要取决于外部调用是“安全”还是“不安全”的调用。
一个“不安全”的C语言调用基本上就是一个函数调用,所以如果没有(非平凡的)类型转换,如果你进行三个外部调用,那么就有三个函数调用;如果在C中编写包装器,根据组件函数可以内联的数量,会产生一个到四个函数调用。因为一个外部调用进入C不能被GHC内联。这样的函数调用通常非常便宜(只是参数的复制和代码的跳转),所以无论如何差异都很小,当没有C函数可以内联到封套程序中时,封套程序应该稍微慢一点,当所有函数都可以内联时,封套程序应该稍微快一些[在我的基准测试中确实是这样,+1.5纳秒和-3.5纳秒,其中三个外部调用花费了约12.7纳秒,仅返回参数]。如果函数执行某些非平凡的操作,则差别可以忽略不计(如果它们没有执行任何非平凡的操作,则最好将它们写成Haskell,以便让GHC内联代码)。
一个“安全”的C语言调用涉及保存非平凡量的状态,锁定,可能会生成新的操作系统线程,因此花费时间更长。然后,也许在C中调用一个额外的函数的小开销与外部调用的成本相比可以忽略不计[除非传递参数需要非常多的复制,很多大型结构]。在我的空操作基准测试中。。。
{-# LANGUAGE ForeignFunctionInterface #-}
module Main (main) where

import Criterion.Main
import Foreign.C.Types
import Control.Monad

foreign import ccall safe "funcs.h cfA" c_cfA :: CInt -> IO CInt
foreign import ccall safe "funcs.h cfB" c_cfB :: CInt -> IO CInt
foreign import ccall safe "funcs.h cfC" c_cfC :: CInt -> IO CInt
foreign import ccall safe "funcs.h cfABC" c_cfABC :: CInt -> IO CInt

wrap :: (CInt -> IO CInt) -> Int -> IO Int
wrap foo arg = fmap fromIntegral $ foo (fromIntegral arg)

cfabc = wrap c_cfABC

foo :: Int -> IO Int
foo = wrap (c_cfA >=> c_cfB >=> c_cfC)

main :: IO ()
main = defaultMain
            [ bench "three calls" $ foo 16
            , bench "single call" $ cfabc 16
            ]

当所有的C函数只返回参数时,单个包装调用的平均值略高于100纳秒[105-112],而三个独立调用的平均值约为300纳秒[290-315]。

因此,一个安全的c调用大约需要100纳秒,通常将它们封装成单个调用更快。但是,如果被调用的函数执行了一些足够复杂的操作,这种差异就不会产生影响。


1
文档中提到,unsafe 调用会停止所有其他的 Haskell 线程。你知道为什么吗? - Thiago Negri
1
在哪里说了这个?我在用户指南中读到,如果程序没有使用-threaded链接,则safe外部调用将停止所有其他Haskell线程。我不明白为什么一个unsafe的外部调用(令人困惑的是,这意味着调用它是安全的,因为它不需要任何预防措施)会这样做。 - Daniel Fischer
3
Edward Z. Yang的帖子《安全第一:FFI和线程》(http://blog.ezyang.com/2010/07/safety-first-ffi-and-threading/)指出,在Haskell运行时系统中,一个`unsafe`外部调用无法被抢占。用户指南称:“如果您需要调用需要很长时间或无限期阻塞的函数,则应将其标记为`safe`并使用`-threaded`。”我猜作者认为,“即使您使用了`-threaded`,如果您使用了`unsafe`,它仍将被阻塞”是隐含的。 - Thiago Negri
2
我认为unsafe调用无法被抢先,因为它被内联到运行时系统中。因此,只有在此调用返回之前,运行时系统才能向另一个Haskell线程转移控制权。而且,当没有线程可用于进行外部调用并保持Haskell RTS调度程序运行时,safe调用可能会打开一个新的操作系统线程。 - Thiago Negri
1
啊,在forkOS的文档中写道:“为了允许进行外部调用而不阻塞所有Haskell线程(使用GHC),在链接程序时只需要使用-threaded选项,并确保外部导入未标记为unsafe即可。”(我强调)。我仍然不知道为什么在原则上RTS不能同时运行具有unsafe调用的其他线程,但至少GHC的实现不能。一个unsafe的外部调用无法被抢占,因为GHC的调度程序只有在线程分配时才会介入,而unsafe调用不会分配(就GHC而言)。 - Daniel Fischer
1
如果我没记错的话,unsafe调用阻塞的不是整个rts,而是IO管理器组件。因此,当HDBC-odbc使用unsafe调用时,长时间运行的数据库查询会阻止我的应用程序接受来自网络的新连接,但不会(如果我记得正确)影响计算能力或写入stdout或stderr的能力。 - sclv

-4

这可能非常取决于您的Haskell编译器、C编译器以及将它们绑定在一起的粘合剂。唯一确定的方法是进行测量。

从更哲学的角度来看,每次混合语言都会为新手创建障碍:在这种情况下,仅精通Haskell和C是不够的(这已经给出了一个狭窄的集合),而且您还必须足够了解调用约定等内容才能使用它们。许多时候需要处理微妙的问题(即使从非常相似的语言C++中调用C也并不是那么容易)。除非有非常充分的理由,否则我会坚持使用单一语言。我能想到的唯一例外是为了创建例如Haskell绑定到现有复杂库的情况,类似于Python的NumPy。


6
我觉得这个回答没有回答提问者的问题。他问的是“做x会有什么性能成本?”,而你的回答似乎归结为“很难说,但不要做x,因为这会让人们更难理解你的代码”。 - gspr
@gspr:不,我说确保的唯一方法是针对他的确切设置进行测量,因为有太多的变量。但他问的是性能问题,(在阅读了Bentley的“编写高效程序”和“编程珠玑”,以及Kernighan和Pike的“编程实践”和Unix群体的其他书籍之后),我坚信“人的时间”比“计算机时间”更昂贵,除非是非常狭窄的情况。所以Knuth的格言“过早优化是万恶之源”是正确的。 - vonbrand
正如其他人所说,你的回答与我的问题无关。问题是:“成本是多少?如何衡量它?”你没有说成本是多少,如果它取决于环境,也没有说如何衡量它。 - Thiago Negri
@ThiagoNegri,编写一个简短的程序,其中包含一个循环什么也不做(作为基准),然后是相同的循环执行您感兴趣的任何操作。从“空”循环中减去时间,然后除以迭代次数。运行几次,确保结果一致。要进行C运算符的广泛基准测试,请查看http://www.cs.bell-labs.com/cm/cs/pearls/appmodels.html。 - vonbrand
@vonbrand:在Haskell中,人们倾向于使用criterion进行基准测试。而您提供的链接似乎并没有涉及从Haskell调用C函数的成本问题。 - gspr
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接