Skip to content

SPECIALIZE is not always transitive within the same module

I recently ran into this issue while working on a re-implementation of the free var collector. According to the GHC manual:

A SPECIALIZE has the effect of generating (a) a specialised version of the function and (b) a rewrite rule (see :ref:rewrite-rules) that rewrites a call to the un-specialised function into a call to the specialised one. Moreover, given a SPECIALIZE pragma for a function f, GHC will automatically create specialisations for any type-class-overloaded functions called by f, if they are in the same module as the SPECIALIZE pragma, or if they are INLINABLE; and so on, transitively.

Here's a small example where the "transitively" part isn't respected:

-- Lib.hs
module Lib
  ( isEven, isOdd )
where

{-# SPECIALIZE isEven :: Int -> Bool #-}
isEven :: Integral a => a -> Bool
isEven = fst evenOdd

{-# SPECIALIZE isOdd :: Int -> Bool #-}
isOdd :: Integral a => a -> Bool
isOdd = snd evenOdd

evenOdd :: Integral a => (a -> Bool, a -> Bool)
evenOdd = (goEven, goOdd)
  where
    goEven n
      | n < 0 = goEven (- n)
      | n > 0 = goOdd (n - 1)
      | otherwise = True

    goOdd n
      | n < 0 = goOdd n
      | otherwise = goEven n
-- Main.hs
{-# LANGUAGE TypeApplications #-}
module Main where
import Lib ( isEven )

main :: IO ()
main = print $ isEven @Int 10

Even though isEven is specialized, the call to isEven in main still passes around a dictionary (see attached files for the full core outputMain.dump-simpl

Lib.dump-simpl):

case Lib.$wevenOdd @Int GHC.Real.$fIntegralInt of
  ...

Checking the core output of Lib.hs, I can see a specialized version of isEven being produced:

Lib.isEven_goEven [InlPrag=NOUSERINLINE[2]] :: Int -> Bool

with the correct rewrite rule at the bottom:

"SPEC isEven"
    forall ($dIntegral_a1Md :: Integral Int).
      isEven @Int $dIntegral_a1Md
      = Lib.isEven_goEven

For some reason that I don't understand, if I specialize evenOdd and isEven, then the call to isEven in Main will get specialized properly.

Note that the example first defines the evenOdd tuple and then projects the two functions out before exporting. If we remove the indirectness of evenOdd and define isEven and isOdd directly at the top-level, then the call in main module is specialized properly:

module Lib
  ( isEven )
where

{-# SPECIALIZE isEven :: Int -> Bool #-}
isEven :: Integral a => a -> Bool
isEven n
  | n < 0 = isEven (- n)
  | n > 0 = isOdd (n - 1)
  | otherwise = True

isOdd :: Integral a => a -> Bool
isOdd n
  | n < 0 = isEven n
  | otherwise = isOdd n

Here's a link to a repo with the examples set up in a cabal project: https://gitlab.haskell.org/yiyunliu/specialization-transitivity-test

The oldest commit is the problematic case where it doesn't specialize. The rest two commits both specialize properly.

To upload designs, you'll need to enable LFS and have an admin enable hashed storage. More information