ekmett/thc

The Turbo Haskell Compiler

Java

177

4,018 commits

updated Oct 4, 2026

See the code

README

thc

Build

Haskell on Truffle/Graal.

I'm experimenting with using GHC as a frontend for a high performance Haskell implementation on the JVM. GHC does the parsing, type checking, desugaring and optimization. THC takes the resulting Core and gives Graal something it can specialize.

GHC already knows quite a lot about compiling Haskell. The intention is to keep that information around long enough to use it.

Build and run

For native Windows, use the PowerShell build and test guide. It lists the required tools and current platform limits.

You need GHC 9.14.1 (including ghc-pkg and runghc), cabal-install 3.16, GraalVM 25.3.4.1 / JDK 25, and Python 3.12+. Put GHC on your PATH and point JAVA_HOME at GraalVM. On macOS, use the bundle's Contents/Home directory. The Gradle wrapper downloads its dependencies on the first build. Linux x86_64 setup includes clang and GMP development headers and libraries (for example, libgmp-dev on Debian/Ubuntu) for native package dependencies.

The JDK is stock, but THC's default distribution includes patched Truffle API, runtime and Sulong JARs. Their patch inventory and shared-host effects are part of the embedding contract: some changes affect other languages using the same runtime, including across separate contexts and engines.

From the repository root:

git -c core.autocrlf=false submodule update --init --depth 1
export JAVA_HOME=/path/to/graalvm
export PATH="$JAVA_HOME/bin:$PATH"

make

This runs ./gradlew installDist for the JVM runtime and cabal build for the Haskell library and driver. The thc library contains the GHC Core plugin; Cabal builds it alongside the thc executable. Both builds are incremental. make runtime and make haskell build either part separately. make keeps Gradle's cache in .gradle-user-home; set GRADLE_USER_HOME to share a cache across checkouts.

THC needs Core, GHC's intermediate representation, for both your program and the Haskell libraries it calls. Standard GHC installations usually omit the complete Core for their bundled libraries, including base and ghc-internal. Compiling those libraries with -fwrite-if-simplified-core keeps every binding in their .hi interface files so THC can load it. The flag must be used when building the libraries; adding it only to your program cannot recover Core from libraries already installed without it.

With a standard GHC 9.14.1 installation, the default --installed-core pinned provider rebuilds selected bundled libraries from the pinned GHC release and caches their complete Core. Their versions, modules and dependencies must match the selected installation. The first acquisition includes this library build.

Alternatively, build GHC's libraries with the flag above and select their retained Core with thc run TARGET --installed-core required. make check-ghc-core GHC=/path/to/ghc checks whether the installed libraries contain the needed Core. The GHC build guide shows how to apply the flag when building GHC 9.14.1, the version THC currently supports.

Fixture tests also require CMake 3.24+ and Ninja. make fixtures builds all admitted fixture files. For routine development, select an exact class, for example make test TESTS=thc.RuntimeTest. make test-modes TESTS=thc.RuntimeTest runs both handoff modes from shared fixture files; see the fixture graph and current limits. make clean removes build products. make distclean also removes the checkout's Gradle caches. make run ARGS='--help' builds and runs the driver; the equivalent Cabal command is cabal run thc -- --help.

Run the included smoke test through THC:

cabal run thc -- run completed --project-dir t/fixtures/run-pure \
  --thc-root "$PWD" --dist-dir "$PWD/build/run-package"

This fixture checks a mutable reference and returns () without printing. Inside your package, thc build [TARGETS...] follows Cabal build selection and acquires dependency Core without launching an application. Omit targets for the current package, use all, or select libraries and executables together. Use thc run [TARGET], following cabal run target syntax, to build and execute. For an installed driver in your own project:

thc run my-package:exe:my-program --thc-root /path/to/thc

Omitting the target selects the current package's sole buildable executable, otherwise its sole buildable runnable component. Explicit PACKAGE:exe:NAME, PACKAGE:test:NAME and PACKAGE:bench:NAME targets work; tests must use the exitcode-stdio-1.0 interface. Use --project-dir or --project-file to select another project. The command builds with Cabal, exports GHC Core, and executes an accepted Main.main :: IO () in THC. Use cabal run thc -- --help for the command-line options. The driver guide has the options and integration check. thc acquire [TARGET] [FLAGS] uses the same acquisition path but stops before auditing or executing the guest; a produced manifest is not a claim that the program is runnable.

A directory containing cabal.project also works for a multi-package build. The integration fixture includes a data library, a native Template Haskell helper, an internal library and an executable:

cabal run thc -- run app-run:exe:completed \
  --project-dir t/fixtures/run-project --thc-root "$PWD" \
  --dist-dir "$PWD/build/run-project"

Cabal builds the native dependencies needed for the helper, and THC runs the executable's accepted Core. The driver uses Cabal's resolved unit IDs and per-component build information for the export.

For native imports, installed-library IO and callbacks, follow the foreign-code setup. Platform permissions and resource lifetimes are explicit; see Windows limits for the native Windows path. General project acquisition (thc build) is currently available on macOS and Linux. thc repl is not implemented.

Runtime capabilities

Both the bytecode and AST backends execute lazy Core with sharing, closures, recursive bindings, typed constructor fields, local joins, unboxed tuples and sums, SIMD, arrays and mutable references. The bytecode backend is the default. Architecture explains the shared value model and the two execution paths.

Asynchronous exceptions, MVars and STM support concurrent Haskell programs. Interrupted shared thunks retain their unfinished work. Ordinary evaluation bounds nested calls and forcing through saved continuations. Load requests and ordinary executable launches default to async off on both backends: off speculates on a single guest admission origin until guest concurrency is admitted. Explicit async opt-in enables polling immediately. Delimited capture across STM, automatic weak finalization and GC-driven deadlock detection remain unsupported.

The public thc:runtime API exposes permissions, thread and affinity observations, memory/GC statistics and structured tracing. Availability and measurement scope are explicit. THC.Internal.JIT supplies separate unstable diagnostics. Polyglot calls and the JVM embedding API support JavaScript and host callers; foreign code describes native imports and exports.

THC remains experimental. The behavior reference lists current semantic and platform limits, and the generated primop checklist inventories operations. Package support also requires complete dependencies, including cold error paths. Use --verify-artifacts for the driver's pre-launch audit and artifact checks. Diagnostic mode leaves explicit traps at unsupported sites; completing one path in that mode does not establish support for its whole closure.

Runtime checks

The test script prepares native GHC fixtures and builds the runtime. The scalar runner then calls one exported entry; --compile requests guest compilation and checks that code was installed:

bin/try.sh
bin/run.sh build/core/THC.Prim.Test.json,build/core/Fixtures.json sumLoop 100000 --compile

The bytecode backend is the default. To use the AST backend:

THC_BACKEND=ast bin/run.sh build/core/THC.Prim.Test.json,build/core/Fixtures.json sumLoop 100000 --compile

THC launchers disable Graal's automatic loop vectorization by default. Explicit JDK Vector API operations remain enabled. To enable automatic loop vectorization for a run, set JAVA_OPTS=-Djdk.graal.VectorizeLoops=true. This is a JVM-wide compiler setting, so it applies to both backends. Gradle application and test JVMs use the same default and respect explicit JVM properties; embedders choose their own JVM options.

Development checks and benchmarks live in src/diagnostics/. Build their separate build/diagnostics/thc-tools.jar with ./gradlew toolsJar; the try scripts do this alongside installDist. Direct Java launches add that JAR to the runtime classpath. The production distribution and JVM API reference exclude these tools and the embedding/polyglot examples in src/examples/.

For the native-checked library suite and diagnostic Map workload:

bin/try-libraries.sh
THC_DIAGNOSTIC_UNSUPPORTED=true bin/try-map.sh

The latter builds, updates, queries and folds a histogram using the actual Data.Map.Strict implementation from containers-0.8.

Performance

Use native-GHC comparisons and compiler graphs to evaluate the workloads and configurations you intend to run. Typed runtime storage and successful guest compilation do not by themselves establish allocation removal or a speedup.

make -C bench kernels OUT="$PWD/work/bench-kernels"
THC_DIAGNOSTIC_UNSUPPORTED=true make -C bench map OUT="$PWD/work/bench-map"
THC_BACKEND=ast THC_DIAGNOSTIC_UNSUPPORTED=true make -C bench map OUT="$PWD/work/bench-ast"

Direct benchmark JVMs also disable automatic loop vectorization by default. Set JDK_JAVA_OPTIONS=-Djdk.graal.VectorizeLoops=true for an explicit comparison with it enabled; inherited JVM options preserve caller choices. bash bin/test-benchmark-entrypoints.sh checks paths and launch defaults without preparing fixtures or measuring.

Choose an unused output directory; the targets prepare and check their inputs before measuring, then overwrite files in the supplied directory. Benchmarks vary their inputs, consume the results, warm the JVM and compare against native GHC. Graph capture is a separate run. Diagnostic Map execution does not establish strict support for its entire dependency closure.

The current runtime guides describe implemented protocols and opt-in experiments.

Finding your way around

The older runtime experiments live on the legacy branch.

License

THC uses the same license as Cadenza: UPL-1.0 AND BSD-3-Clause. See LICENSE.txt and NOTICE.md for the terms and attribution notices.

Unless you explicitly state otherwise, contributions submitted for inclusion are provided under these same terms.

Contact Information

Contributions and bug reports are welcome!

Please feel free to contact me through GitHub or on the ##thc IRC channel on irc.libera.chat (Libera Chat).

-Edward Kmett

compiler
graalvm
haskell
java
jit
kotlin
truffle-framework

Significant stargazers

Sridhar Ratnakumar

1,014 followers · starred Sep 2026

Robin Green

41 followers · starred Sep 2015

Sven Keidel

35 followers · starred Sep 2015

acertain

7 followers · starred Dec 2016

ekmett/thc

The Turbo Haskell Compiler

Java

177

4,018 commits

updated Oct 4, 2026

See the code

README

thc

Build

Haskell on Truffle/Graal.

I'm experimenting with using GHC as a frontend for a high performance Haskell implementation on the JVM. GHC does the parsing, type checking, desugaring and optimization. THC takes the resulting Core and gives Graal something it can specialize.

GHC already knows quite a lot about compiling Haskell. The intention is to keep that information around long enough to use it.

Build and run

For native Windows, use the PowerShell build and test guide. It lists the required tools and current platform limits.

You need GHC 9.14.1 (including ghc-pkg and runghc), cabal-install 3.16, GraalVM 25.3.4.1 / JDK 25, and Python 3.12+. Put GHC on your PATH and point JAVA_HOME at GraalVM. On macOS, use the bundle's Contents/Home directory. The Gradle wrapper downloads its dependencies on the first build. Linux x86_64 setup includes clang and GMP development headers and libraries (for example, libgmp-dev on Debian/Ubuntu) for native package dependencies.

The JDK is stock, but THC's default distribution includes patched Truffle API, runtime and Sulong JARs. Their patch inventory and shared-host effects are part of the embedding contract: some changes affect other languages using the same runtime, including across separate contexts and engines.

From the repository root:

git -c core.autocrlf=false submodule update --init --depth 1
export JAVA_HOME=/path/to/graalvm
export PATH="$JAVA_HOME/bin:$PATH"

make

This runs ./gradlew installDist for the JVM runtime and cabal build for the Haskell library and driver. The thc library contains the GHC Core plugin; Cabal builds it alongside the thc executable. Both builds are incremental. make runtime and make haskell build either part separately. make keeps Gradle's cache in .gradle-user-home; set GRADLE_USER_HOME to share a cache across checkouts.

THC needs Core, GHC's intermediate representation, for both your program and the Haskell libraries it calls. Standard GHC installations usually omit the complete Core for their bundled libraries, including base and ghc-internal. Compiling those libraries with -fwrite-if-simplified-core keeps every binding in their .hi interface files so THC can load it. The flag must be used when building the libraries; adding it only to your program cannot recover Core from libraries already installed without it.

With a standard GHC 9.14.1 installation, the default --installed-core pinned provider rebuilds selected bundled libraries from the pinned GHC release and caches their complete Core. Their versions, modules and dependencies must match the selected installation. The first acquisition includes this library build.

Alternatively, build GHC's libraries with the flag above and select their retained Core with thc run TARGET --installed-core required. make check-ghc-core GHC=/path/to/ghc checks whether the installed libraries contain the needed Core. The GHC build guide shows how to apply the flag when building GHC 9.14.1, the version THC currently supports.

Fixture tests also require CMake 3.24+ and Ninja. make fixtures builds all admitted fixture files. For routine development, select an exact class, for example make test TESTS=thc.RuntimeTest. make test-modes TESTS=thc.RuntimeTest runs both handoff modes from shared fixture files; see the fixture graph and current limits. make clean removes build products. make distclean also removes the checkout's Gradle caches. make run ARGS='--help' builds and runs the driver; the equivalent Cabal command is cabal run thc -- --help.

Run the included smoke test through THC:

cabal run thc -- run completed --project-dir t/fixtures/run-pure \
  --thc-root "$PWD" --dist-dir "$PWD/build/run-package"

This fixture checks a mutable reference and returns () without printing. Inside your package, thc build [TARGETS...] follows Cabal build selection and acquires dependency Core without launching an application. Omit targets for the current package, use all, or select libraries and executables together. Use thc run [TARGET], following cabal run target syntax, to build and execute. For an installed driver in your own project:

thc run my-package:exe:my-program --thc-root /path/to/thc

Omitting the target selects the current package's sole buildable executable, otherwise its sole buildable runnable component. Explicit PACKAGE:exe:NAME, PACKAGE:test:NAME and PACKAGE:bench:NAME targets work; tests must use the exitcode-stdio-1.0 interface. Use --project-dir or --project-file to select another project. The command builds with Cabal, exports GHC Core, and executes an accepted Main.main :: IO () in THC. Use cabal run thc -- --help for the command-line options. The driver guide has the options and integration check. thc acquire [TARGET] [FLAGS] uses the same acquisition path but stops before auditing or executing the guest; a produced manifest is not a claim that the program is runnable.

A directory containing cabal.project also works for a multi-package build. The integration fixture includes a data library, a native Template Haskell helper, an internal library and an executable:

cabal run thc -- run app-run:exe:completed \
  --project-dir t/fixtures/run-project --thc-root "$PWD" \
  --dist-dir "$PWD/build/run-project"

Cabal builds the native dependencies needed for the helper, and THC runs the executable's accepted Core. The driver uses Cabal's resolved unit IDs and per-component build information for the export.

For native imports, installed-library IO and callbacks, follow the foreign-code setup. Platform permissions and resource lifetimes are explicit; see Windows limits for the native Windows path. General project acquisition (thc build) is currently available on macOS and Linux. thc repl is not implemented.

Runtime capabilities

Both the bytecode and AST backends execute lazy Core with sharing, closures, recursive bindings, typed constructor fields, local joins, unboxed tuples and sums, SIMD, arrays and mutable references. The bytecode backend is the default. Architecture explains the shared value model and the two execution paths.

Asynchronous exceptions, MVars and STM support concurrent Haskell programs. Interrupted shared thunks retain their unfinished work. Ordinary evaluation bounds nested calls and forcing through saved continuations. Load requests and ordinary executable launches default to async off on both backends: off speculates on a single guest admission origin until guest concurrency is admitted. Explicit async opt-in enables polling immediately. Delimited capture across STM, automatic weak finalization and GC-driven deadlock detection remain unsupported.

The public thc:runtime API exposes permissions, thread and affinity observations, memory/GC statistics and structured tracing. Availability and measurement scope are explicit. THC.Internal.JIT supplies separate unstable diagnostics. Polyglot calls and the JVM embedding API support JavaScript and host callers; foreign code describes native imports and exports.

THC remains experimental. The behavior reference lists current semantic and platform limits, and the generated primop checklist inventories operations. Package support also requires complete dependencies, including cold error paths. Use --verify-artifacts for the driver's pre-launch audit and artifact checks. Diagnostic mode leaves explicit traps at unsupported sites; completing one path in that mode does not establish support for its whole closure.

Runtime checks

The test script prepares native GHC fixtures and builds the runtime. The scalar runner then calls one exported entry; --compile requests guest compilation and checks that code was installed:

bin/try.sh
bin/run.sh build/core/THC.Prim.Test.json,build/core/Fixtures.json sumLoop 100000 --compile

The bytecode backend is the default. To use the AST backend:

THC_BACKEND=ast bin/run.sh build/core/THC.Prim.Test.json,build/core/Fixtures.json sumLoop 100000 --compile

THC launchers disable Graal's automatic loop vectorization by default. Explicit JDK Vector API operations remain enabled. To enable automatic loop vectorization for a run, set JAVA_OPTS=-Djdk.graal.VectorizeLoops=true. This is a JVM-wide compiler setting, so it applies to both backends. Gradle application and test JVMs use the same default and respect explicit JVM properties; embedders choose their own JVM options.

Development checks and benchmarks live in src/diagnostics/. Build their separate build/diagnostics/thc-tools.jar with ./gradlew toolsJar; the try scripts do this alongside installDist. Direct Java launches add that JAR to the runtime classpath. The production distribution and JVM API reference exclude these tools and the embedding/polyglot examples in src/examples/.

For the native-checked library suite and diagnostic Map workload:

bin/try-libraries.sh
THC_DIAGNOSTIC_UNSUPPORTED=true bin/try-map.sh

The latter builds, updates, queries and folds a histogram using the actual Data.Map.Strict implementation from containers-0.8.

Performance

Use native-GHC comparisons and compiler graphs to evaluate the workloads and configurations you intend to run. Typed runtime storage and successful guest compilation do not by themselves establish allocation removal or a speedup.

make -C bench kernels OUT="$PWD/work/bench-kernels"
THC_DIAGNOSTIC_UNSUPPORTED=true make -C bench map OUT="$PWD/work/bench-map"
THC_BACKEND=ast THC_DIAGNOSTIC_UNSUPPORTED=true make -C bench map OUT="$PWD/work/bench-ast"

Direct benchmark JVMs also disable automatic loop vectorization by default. Set JDK_JAVA_OPTIONS=-Djdk.graal.VectorizeLoops=true for an explicit comparison with it enabled; inherited JVM options preserve caller choices. bash bin/test-benchmark-entrypoints.sh checks paths and launch defaults without preparing fixtures or measuring.

Choose an unused output directory; the targets prepare and check their inputs before measuring, then overwrite files in the supplied directory. Benchmarks vary their inputs, consume the results, warm the JVM and compare against native GHC. Graph capture is a separate run. Diagnostic Map execution does not establish strict support for its entire dependency closure.

The current runtime guides describe implemented protocols and opt-in experiments.

Finding your way around

The older runtime experiments live on the legacy branch.

License

THC uses the same license as Cadenza: UPL-1.0 AND BSD-3-Clause. See LICENSE.txt and NOTICE.md for the terms and attribution notices.

Unless you explicitly state otherwise, contributions submitted for inclusion are provided under these same terms.

Contact Information

Contributions and bug reports are welcome!

Please feel free to contact me through GitHub or on the ##thc IRC channel on irc.libera.chat (Libera Chat).

-Edward Kmett

compiler
graalvm
haskell
java
jit
kotlin
truffle-framework

Significant stargazers

Sridhar Ratnakumar

1,014 followers · starred Sep 2026

Robin Green

41 followers · starred Sep 2015

Sven Keidel

35 followers · starred Sep 2015

acertain

7 followers · starred Dec 2016

Languages

Java

65.3%

Haskell

23.7%

Python

9.3%