Header

原文标题:Similarity between instruction scheduling and imperative functional programming
作者:Oleg Kiselyov
原文链接:https://okmij.org/ftp/Haskell/fp-arrays-assembly.txt

将「典型的命令式编程代码」转换为函数式编程代码的过程,似乎有些类似于现代 CPU 编译器中使用的数据流和控制流依赖分析——所谓典型命令式代码,就是涉及了嵌套循环和可变数组的「典型数值计算」代码。下面将考虑三个此类转换的例子。

第一个例子在本主题之前的回帖中就已经讨论过,为了使该代码的内循环更有意义,这里对其稍作修改。该示例的命令式形式为

for(int i=1; i<=zx; i++)
  for(int j=1, a=i+1, b=i*j; j<=zx; j+=1, a+=1, b+=1)
    for(z=j; z<=zu; z+=2)
      array[z] = a, array[z+1] = b;

这个例子其实很简单。我们注意到代码只会写入 array,而不会读取 array,也没有控制流分支,唯一需要担心的就是更新 array 操作的顺序性问题。那么,解决方案就呼之欲出了——首先生成一个「更新列表」,这个列表告诉我们应该将数组的某个元素设置为某个值;生成列表之后,我们再按列表执行更新。我们可以用函数式编程生成这样的列表,然后用命令式编程——Monad 来执行。而且由于 Haskell 的惰性求值特性,我们可以一边生成更新列表的元素,一边执行。

下面是对应的 Haskell 代码

import ST
-- 提取代码中所有更新数组的操作
ex1_update_list zx zu = concat $
  [ [(z,a), (z+1,b)] |
    i <- [1..zx],
    (j,a,b) <- takeWhile (\(j,_,_) -> j<=zx) $
            iterate (\(j,a,b) -> (j+1,a+1,b+1)) (1,i+1,i*i),
    z <- [j,j+2..zu]
  ]

-- 按顺序执行列表中的所有更新操作
-- 这里用 mapM_ 来执行更新
do_loops = do { array <- newSTArray (1,4) 0;
           mapM_ (\(idx,new_value) -> writeSTArray array idx new_value) $
                 ex1_update_list 4 3;
           return array }

-- 试运行上述代码,观察其返回值
array_to_list array = sequence
 [ readSTArray array i |
     let (lwb,upb) = boundsSTArray array, i<-[lwb..upb] ]

compute = runST( do_loops >>= array_to_list )

为了加载上述代码,读者需要带 -98 命令行参数启动 Hugs1;加载完成后,在 REPL 提示符下输入 compute 即可查看结果。请注意,在函数式版本的代码中,代码中的嵌套循环看起来被翻了个底朝天:原本在内循环的「数组赋值」操作现在出现在了外循环中,变成了 do_loops 中的一条语句。

第二个例子则要简单得多:给定整数数组 array[lwb..upb],求数组中大于等于 7 的元素的和。同理,我们先从一个命令式实现开始:

int sum = 0;
for(int i=lwb; i<upb; i++)
   if(array[i] >= 7)
     sum += array[i];

对该循环进行分析,可以发现数组上不存在数据流和控制流的依赖关系:程序只是读取数组,而且最终会读取数组中的所有元素。这意味着我们可以预读数组的所有元素,而且可以很容易地把循环并行化。

-- 推测式预取数组所有元素,该操作不安全
-- 我们假设代码不存在任何数据流依赖关系
import IOExts(unsafePerformIO)
prefetch_array array = map (unsafePerformIO . stToIO)
  [ readSTArray array i | 
     let (lwb,upb) = boundsSTArray array, i<-[lwb..upb] ]

-- 我们借用之前定义的 do_loops 函数来获取用于处理的数组
ex3 = sum $ filter (>=7) $ prefetch_array $ unsafePerformIO $ 
            stToIO do_loops

正如我们的分析所示:求和循环确实是纯函数式的。prefetch_array 会「推测性」地预取数组元素——用数据库领域的术语来说,该函数进行的是脏读[dirty read]。我们注意到 map 会非确定性地将函数应用到列表元素上,但我们不存在数据依赖,所以这一点并不重要。同理,map 的内部循环也是纯函数式的。

第三个例子则是一个愚蠢的排序问题:对数组 [0..(n-1)] 按升序排序。

for(i=1; i<n; i++)
{
  bool done_already = true;
  for(j=0; j<n-i; j++)
    if( array[j] > array[j+1] )
    {
      int temp = array[j];
      array[j] = array[j+1];
      array[j+1] = temp;
      done_already = false;
    }
  if( done_already )
    break;
}

这一算法在小数组上应该运行得不错,如果数组的顺序已经(基本)正确,那就更好不过了。

但对于超标量 CPU 而言,想要高效执行这段命令式代码其实相当复杂。我们可以看到迭代过程中存在复杂的数据依赖关系、代码对数组的读取和写入、依赖于数组元素和迭代历史记录的条件分支,以及两个循环间的控制流依赖关系等等。

下面用 Haskell 语言展示了这一问题的两种解法,两者都使用了快速、命令式的 STArrays;两者都试图将命令式部分和函数式部分分离:命令式部分在 Monad 中执行,而函数式部分则是普通 Haskell。这两种解决方案关系密切,在编写两种方案时,作者也有意强调了它们之间的相似关系。

首先提出的是最怪异的解决方案:

data Predicate = P1 -- 谓词寄存器 1
           | P2     -- 谓词寄存器 2
           | Ptrue  -- 真值
           | Pfalse -- 假值
        deriving Show

-- 每条指令都引用了某个谓词:仅当引用的谓词为真时,指令才会执行
data Instruction = LDA Predicate Integer -- 从某内存位置读取到累加寄存器 A 中
         | LDB Predicate Integer         -- 从某内存位置读取到累加寄存器 B 中
         | SET_gt Predicate Predicate    -- 若 A>B,设置谓词
         | CLR Predicate Predicate       -- 清除谓词
         | SET Predicate Predicate       -- 设置谓词
         | STA Predicate Integer         -- 将累加寄存器 A 存入某内存位置
         | STB Predicate Integer         -- 将累加寄存器 B 存入某内存位置
         | HALT Predicate
        deriving Show

ex2_instr_stream n = concat
 [[SET Ptrue P2] ++ 
  concat [[LDA Ptrue j, LDB Ptrue (j+1), SET_gt Ptrue P1,
           STA P1 (j+1), STB P1 j, CLR P1 P2] | j<-[0..(n-i-1)]] ++
  [HALT P2] | i<-[1..(n-1)]]

这一解决方案展示了一种所谓谓词指令执行[predicate instruction execution]方式,目前最先进的 CPU(如 Itanium)以及 Konrad Zuse 的 Z-3 中就使用了这种指令执行方式。读者不妨自己在 Hugs 提示符下输入 ex2_instr_stream n 并观察结果,但我们现在先暂时按下不表,来看看第二种解决方案:

ex2_hi_stream n = concat
 [ [\array done_already -> return $ Just True] ++
   [\array done_already -> do {
        a <- readSTArray array j;
        b <- readSTArray array (j+1);
        if a > b then
           writeSTArray array j b >>
           ( writeSTArray array (j+1) a >>
             ( return $ Just False ))
        else return $ Just done_already } | j<-[0..(n-i-1)] ] ++
   [\array done_already -> return $ 
                     if done_already then Nothing else Just done_already]
      | i<-[1..(n-1)]]

这一解决方案是 ex2_instr_stream 的高级版本;两个版本的函数都会产生一条用于执行的指令流,它们都把嵌套的循环规约成了一串指令流——这就是循环的「纯函数式」部分,而真正执行指令的代码就代表了循环的「命令式」部分。

-- fold 的一种变体,支持提前终止
executor init_state commands array = run init_state commands
  where
       run Nothing _ = return array
       run _ []      = return array
       run (Just state) (cmd:cmds) = (cmd array state) >>=
                                \new_state -> run new_state cmds

-- 用于排序的简单数组
sample_array = do {
                array <- newSTArray (0,4) 0;
                writeSTArray array 0 5;
                writeSTArray array 1 7;
                writeSTArray array 2 1;
                writeSTArray array 3 8;
                writeSTArray array 4 9;
                return array }

ex2_hi = runST( sample_array >>=
                executor (Just True) (ex2_hi_stream 5)
                >>= array_to_list )

这些解决方案确实有效:在 Hugs 提示符下输入 ex2_hi 即可查看结果。

我们之前曾提出 ex2_hi_stream 生成的高级指令流与 ex2_instr_stream 生成的低级指令流存在关系。事实也确实如此,高级指令与低级指令间有一映射 alu 满足:

ex2_lo = runST( sample_array >>= 
             executor (Just (0,0,True,True)) (map alu $ ex2_instr_stream 5)
                >>= array_to_list )

alu 代表我们虚拟机的算术逻辑单元[Arithmetic Logic Unit, ALU],该函数的具体实现参见附录。而 executor 函数代表了我们虚拟 CPU 的控制单元。

分析循环迭代间的数据流和控制流依赖关系、调度指令、实现互锁[interlock]机制、预取内存数据……这些都是 CPU 或高级编译器的工作。但另一方面,人类在设计算法时也需进行类似的分析,从而分离计算的纯函数部分和命令式部分。函数式编程似乎要比人们所普遍认为的更接近裸机[bare metal] 。

这里还可以举出更多的例子:譬如为了用函数式语言重写各种数值算法(高斯消除、卷积滤波等),对其进行数据流/控制流依赖性分析。但这篇文章已经太长,太无聊了。

后日谈

Andrew W. Appel 在他的文章 «SSA is Functional Programming» (ACM SIGPLAN Notices v. 33, no. 4, pp. 17-20, April 1998) 中写道:

The SSA (Static Single Assignment) algorithm … is automatically converting a FORTRAN or C procedure into a well-structured functional program with nested scope. Actually, I’ve only shown what to do with scalar variables. Arrays are handled in high-powered (parallelizing) compilers using sophisticated dependence analysis techniques [15], which is another way of extracting the functional program inside the imperative one.
译:所谓静态单赋值形式[Static Single Assignment, SSA]算法……就是一种自动将一个 Fortran 或 C 程序转换成一个结构良好,具有嵌套作用域的函数式程序的算法。实际上,本文中只展示了如何处理标量变量,高端(并行化)编译器中往往会通过复杂的依赖性分析技术来处理数组 [15],这也是一种在命令式程序中提取函数式程序的方法。

引文的最后一段正是本文的主旨:我们人工分析了几个就地更新数组的代码片段的依赖性,通过分析,我们提取了命令式程序中的纯函数式部分。而函数式以外的部分则需要序列化,这些代码可以交给 ST monad 处理。

附录:ALU 函数

--   ALU State        AccA AccB  P1   P2
type ALUState elem = (elem,elem,Bool,Bool)
type Storage a elem = STArray a Integer elem
alu:: (Ord elem) => Instruction -> Storage a elem -> ALUState elem ->
ST a (Maybe (ALUState elem))
alu instr array state@(a,b,p1,p2) = case instr of
       LDA p loc -> provided p $ 
                     readSTArray array loc >>= \a->return $ Just (a,b,p1,p2)
       LDB p loc -> provided p $ 
                     readSTArray array loc >>= \b->return $ Just (a,b,p1,p2)
       STA p loc -> provided p $ 
                     writeSTArray array loc a >> (return $ Just state)
       STB p loc -> provided p $ 
                     writeSTArray array loc b >> (return $ Just state)
       SET p P1 -> provided p $ return $ Just (a,b,True,p2)
       SET p P2 -> provided p $ return $ Just (a,b,p1,True)
       CLR p P1 -> provided p $ return $ Just (a,b,False,p2)
       CLR p P2 -> provided p $ return $ Just (a,b,p1,False)
       SET_gt p P1 -> provided p $ return $ Just (a,b,a>b,p2)
       SET_gt p P2 -> provided p $ return $ Just (a,b,p1,a>b)
       HALT p ->  provided p $ return Nothing
  where
       provided P1 = if p1 then id else \_ -> return $ Just state
       provided P2 = if p2 then id else \_ -> return $ Just state
       provided Ptrue = id
       provided Pfalse = \_ -> return $ Just state

参考