This package has targets for the frontend of the code generation system, using an approach that generalizes arbitrary Haskell into a categorical representation that can then be translated to various targets (for example, C code).
For this plugin to work, you need to make some changes both to targets that use it directly, as well as ones that are depended on (transitively) by the ones that use it.
- enable the plugin with
-fplugin=Categorifier
, - ensure inlining is available with
-fno-ignore-interface-pragmas
(implied by-O
or-O2
), and - import
Categorifier.Categorify
to makecategorify
available (you must import the entire module, but it may be qualified).
- use
-fno-omit-interface-pragmas
(implied by-O
or-O2
) to make sure inlinings are available and - use
-fexpose-all-unfoldings
to avoid having to add aninlinable
pragma to every definition that may need to be inlined by the plugin. NB: This flag adds the optimized definition to the interface file, whereasinlinable
adds the original definition. This distinction may be important during conversion.
- define instances for your target category using your preferred type class hierarchy (the default
is
base
) - define
Categorifier.HasRep
instances for any types that you use in a converted function (the plugin will tell you if you are missing any when you try to convert)
It can be difficult to find a reasonable setting for the various inlining thresholds. This attempts to lay out an approach for identifying one.
There are two significant GHC flags for adjusting inlining, -funfolding-creation-threshold
and
-funfolding-use-threshold
. They allow you to set an upper bound on the "size" of unfoldings that
will be considered for inlining.
- set the
creation
(globally) threshold high, say10000
; - test to see if the inlining issue goes away (if so, skip to step 5);
- set the
use
(incategorify
modules) threshold to match thecreation
threshold; - do a binary search on the
use
thresholds to minimize them as much as possible; - do a binary search on the
creation
thresholds to minimize them as much as possible (the lower bound here is probably the minimum of 750 (the default) and theuse
threshold).
If either if these values is too small, you'll end up with errors complaining that some definition
couldn't be inlined. If they're too big, you'll get errors about "simplifier ticks exhausted" (in
which case, you can bump -fsimpl-tick-factor
) and things will take a lot longer to compile.
You should use Categorifier.Client.deriveHasRep
for all HasRep
instances. However, for
some GADTs you'll currently have to write out a more direct instance.
The plugin attempts to convert a large part of Haskell to a category-agnostic form, however there are some features that aren't supported (or, not supported fully).
FFI
- The plugin can not categorify arbitrary FFI calls. There is, however, support for parts oflibm
.IO
- The plugin can not categorify anything that operates on theRealWorld
state. If you only useMonad
operations (etc.) onIO
, then it should categorify fine. But then you should also generalize the function to an arbitraryMonad
.LinearTypes
- We can categorify plugins with arbitrary multiplicities (for example, linear functions), but we don't yet take advantage of Evaluating Linear Functions to Symmetric Monoidal Categories to produce simpler categorical models.- mutual recursion - The plugin won’t work with any mutually-recursive definitions. Mutually-
recursive types are fine, but operations on them can't be mutually-recursive. Sometimes
(mutually-recursive
let
orwhere
bindings), the plugin can identify it and will give a helpful error. However, in other cases (top-level bindings) the plugin can't identify the mutual recursion andcategorify
will get stuck in a loop during compilation. - polymorphic recursion - The plugin doesn’t currently work with polymorphically-recursive functions that need to be inlined (rather than directly interpreted).
- polymorphism - We can
categorify
polymorphic functions. However, the polymorphism may only be constrained by operations that we can interpret directly to the target category. For example, functions with types likeforall a. Foo a -> Int
orforall a. Ord a => Foo a -> Int
can be categorified, butforall a. MyCustomClass a => Foo a -> Int
may not be able to. (IfMyCustomClass
simply aggregates operations from type classes inbase
, then it will still categorify.)
A call to Categorifier.Categorify.expression
must be fully saturated to trigger the rewrite
rule installed by the plugin. For example, expression . f $ x
or
(if b then expression else somethingElse) (f x)
doesn’t trigger the plugin, but
expression $ f x
and expression (f x)
do.
If you need to support a particular kind of partial application (such as expression . foo $ bar
),
you may want to install an extra CoreToDo
, which runs before the plugin and performs the
necessary rewriting (for example, rewrite expression . f $ x
into expression (f x)
).
BuildDictionary.hs
is responsible for building type class dictionaries needed by the plugin to
satisfy constraints. It currently has a limitation that it can only find orphan instances in
exposed packages, not hidden packages. For example, suppose A.hs
, B.hs
and C.hs
are in
target a
, b
and c
, respectively. A.hs
imports B.hs
and B.hs
imports C.hs
, but A.hs
doesn’t
directly import C.hs
, and target a
doesn’t directly depend on target c
. Then, if C.hs
has an orphan instance, BuildDictionary.hs
won't see it when compiling A.hs
, because
C.hs
is in a hidden package c
. The workaround is to make target a
directly depend
on target c
, thereby exposing c
to a
(but you don't need to import C.hs
in A.hs
).
Conal Elliott's original implementation of his Compiling to Categories paper. It's the basis for this work, but has a lot of complexity and shortcomings that we've tried to address here. Some of the improvements we've made:
- require less monomorphization,
- support alternative type class hierarchies (including support for the
Arrow
hierarchy inbase
, - better error handling (both in terms of catching more things at compile time and having clearer messages), and
- support for sum types (in the paper, but not the current implementation of concat)
- some support for recursion via traced monoidal categories
- require fewer fiddly pragmas (for example,
inline
andrules
) and other modifications to the client code, - simpler implementation with better modularity and more comments to hopefully make understanding the internals more accessible.
This is a more general package, but it does offer
Overloaded.Categories
,
which is like Compiling to Categories applied to the Arrow
language
extension.
This is ostensibly a more correct approach, given the way GHC is structured, but it relies on
Arrow
(the extension, not the type class), and so the result is much less idiomatic Haskell.
There are a bunch of modules, this calls out the most important ones when diving in.
Categorifier
- this is the entrypoint of the plugin, everything that hooks into GHC starts from here;Categorifier.Core.Categorify
- the high-level logic of the categorical transformation as described in Conal's paper, it tries to define as clearly as possible the mapping from Hask to abstract categories;Categorifier.Hierarchy
- the mappings from abstract categories to specific type class hierarchies.Categorifier.Test.Hask
,Categorifier.Test.Term
,Categorifier.Test.TotOrd
- various categories defined (often against many type class hierarchies) for testing the plugin.
Writing a plugin is mostly the same as vanilla Haskell. The primary distinctions are
- you have an entrypoint other than
main
and - you have to use the GHC API.
The first means that there is a lot of stuff happening outside your code, and your code may be called zero or many times, the expressions that your code is applied to may be different than what you were expecting, or than what you created in a previous step, etc.
The second means dealing with decades of evolutionary code. We try our best to code defensively
(for example, call foo_maybe
instead of foo
), but things like panic
s and other surprising
behavior still surface.
As we work through the implementation, the types of debugging we use may change, so this section is expected to churn a bit, as new approaches are added and old ones are obsolesced.
We use a flag, Categorifier:defer-failures
, to keep conversion failures from crashing GHC. However,
for the time being, all deferred failures are identical (SW-) -- they don't carry any
information about what failed. This makes them harder to debug. What you should do is constrain your
testing to exactly the failed test. That means
- comment out the line in plugins.bzl that mentions
Categorifier:defer-failures
, - in TH.hs, comment out all the
testTerms
other than the failing one, - in the Main.hs for the appropriate hierarchy, comment out the other
*TopLevel
entries in the list, then - run a specific hierarchy test, for example,
concat-class-hierarchy
.
Not all these steps are always necessary, but it can be hard to know when you can omit one.
This is a bit tedious, for sure. But it does often make the loop faster, and it ensures no other errors confuse issues. In future, we should preserve the actual failure for the test so it's easier to inspect what's happening less invasively.
The last case of findMaker
tries to inline the identifier, which can be useful to track but it's
possible for this to cause a cycle with categorifyLambda (Plugins.App ...)
. To discover if you're
running into this, replace the Nothing
with error [fmt|missing: {modu}.{var} {length args}|]
and
see if you're trying to match the correct application.
We compile all the hierarchy tests with -dcore-lint
, which catches cases where we’re generating
at least slightly off (and possibly bogus) Core.
The Core output (from either -dcore-lint
or our own tracing) can be pretty noisy, so
-dsuppress-all
substantially shortens the output, but it can also hide some critical
information. You can control the suppression a bit more with options like -dsuppress-coercions
and
-dsuppress-type-applications
(and their -dno-
inverses).
If you need to inspect Core when there isn't a linting issue, you can add -dverbose-core2core
to
see every transformation that happens.
Sometimes the plugin doesn't terminate (hopefully we can completely eliminate this possibility by treating conversion as an inductive fold in future). When this happens, there are a few ways you can recover what's happening.
If your GHC was built with libdw
support (ours currently isn't) then you can send
SIGQUIT
(or Ctrl-\
) to the process to get a stack trace of where it exited.
Or, sending SIGUSR2
will cause any accumulated IO
actions to run before the program
exits. This can be useful for recovering any tracing output.