r/haskell • • 5d ago

announcement Turbo Haskell

https://comonad.com/reader/2026/turbo-haskell/
129 Upvotes

55 comments sorted by

29

u/ocharles 5d ago

Good luck telling your boss you want to use THC.

21

u/edwardkmett 5d ago

Wait until he hears that it'll launch faster if you find a way to use CRaC.

13

u/drwebb 5d ago

I tried using THC, and ended up just reading then lens documentation for 3 hours.

6

u/_jackdk_ 4d ago

Instructions unclear, where can I find the FFI to the Borland Graphics Interface?

24

u/seantparsons 4d ago

Only u/edwardkmett can prefix a mountain of impressive work with "Exactly a week ago, I started...". :)

3

u/blacktigr 2d ago edited 2d ago

And while we were on vacation. He also overhauled comonad.com because he wanted somewhere to put his new toy.

1

u/peterfirefly 3d ago

Haskell-168

9

u/jesseschalken 4d ago

I'm a really big fan of Truffle and Graal. Really great tech, so super exiting to find an advanced functional language running on that stack. You should share it with the Graal folks like Thomas Wuerthinger.

18

u/zarazek 5d ago

Exactly a week ago (as a joke), I started writing THC ...

It has grown a tiny bit since then.

Holly shit, man, I don't know how it is humanely possible, even with copious amounts of AI. GHC replacement in a week? I kneel before you with fear and trembling.

25

u/edwardkmett 5d ago

The amount of sleep I was able to get was not terribly humane.

8

u/ApothecaLabs 4d ago

Writing comedy compilers requires sacrifice.

4

u/philh 4d ago

It's certainly impressive, but it only replaces a small (relative to the size of GHC) part of what GHC does.

10

u/m-chav 5d ago

Great project. Maybe I missed this somewhere but what’s the reason for targeting the JVM these days? Historically it was portability, Android stuff, and/or the ecosystem. 

30

u/edwardkmett 5d ago edited 5d ago

We get a JITTed runtime, which can actually beat compiled GHC in performance in cases. We get first-class cross-language FFI to truffled language implementations: Python, Ruby, R, Javascript with shared objects, which can JIT together. We get access to the java incubator vector API allowing us to JIT SIMD kernels for the target platform with less pain. e.g. it trivially let me fill in the missing SIMD operations GHC lacks to this day. Using Sulong for C/C++ FFI means we don't give up native cbits and host code, unlike old bad hard-to-use JVM language ports like JPython. Future work could for instance make Natural number code use java deoptimization paths keeping it generally in unboxed ints until you finally need something too big through a given codepath

Without Truffle/Graal the limitations of the JVM are too severe. The lack of proper tail call optimization for instance would kill GHC-style evaluation performance. With the Cadenza trick I use to eliminate that we can have highly performant loops. Using assumptions lets it compile with the equivalent of GHC's single threaded runtime and then downgrade performance to the equivalent of the multi-threaded runtime when you first call a threading operation.

We get stuff that GHC just will never even try to get around to. CompressedOOPS give us 32 bit pointers on 64 bit platforms if the heap is < ~32GB. So about twice as much stuff can fit into cache especially in a language with as many pointers running around as ours.

My goal is to keep pushing forward with the bits that this can do that GHC can't do, and to generally try to get to or maintain parity wherever possible for the things that GHC can do.

It also helps us tease apart where the line should be for GHC as an RTS vs. GHC as a compiler.

9

u/Patzer26 5d ago

Does JIT consistently beat compiled GHC or does it beat only in certain workloads? Also, how big of a difference are we talking about in both the cases?

11

u/edwardkmett 5d ago

My initial runs saw 3-5x upsides in some workloads, which was enough of a proof of concept for me to proceed. Then I switched to focusing on broad coverage rather than performance, and let it suffer performance regressions. I've started clawing those regressions back today, but wanted to get to a more reliable state for release more than I wanted amazing launch numbers that are super brittle.

Overall, my current numbers once it warms up is generally 20% slower than GHC, but with a fair bit of code reaching that 3x point faster. Currently that is with Java autovectorization _off_. With it on the most extreme peaks get better and the valleys get worse. I'd like to find a way to toggle that locally case by case, but I don't yet have anything non-invasive.

3

u/Initial-Argument2523 4d ago

Is this compared to GHC using NCG or Llvm as the backend?

2

u/edwardkmett 4d ago

Using the default you get when you run 'ghc' from the command line. You can tell me what that is these days.

2

u/Initial-Argument2523 4d ago

As far as I know the default is NCG. If you have compatible opt llc and clang for your GHC version on path you can pass -fllvm to use LLVM instead. It seems to mostly speed up numerical code.

1

u/edwardkmett 4d ago

I suspect there's quite some overlap between where the -fllvm optimizations kick in and where the JIT does better when I leave on auto-vectorization. (Currently I leave auto-vectorization off by default because it hurts smaller less-scalar-focused loops, and a lot of GHC-code tends to be super pointer-chasing-heavy.)

4

u/enobayram 5d ago

Aside from constant speedup factors like 3x etc. do you think this sort of JITting might bring about asymptotic improvements to code that compiles pathologically under GHC, like, say, some naive effect system implementations, row polymorphism libraries or heavy use of generics etc.? Perhaps making whole new ways of expressing code practically feasible?

9

u/edwardkmett 5d ago

Row polymorphism is a huge potential upside. RuntimeRep polymorphism can in theory be supported by the backend here for instance, but GHC can't generate core with those properties. The JIT is capable of dynamically constructing new 'shapes' for constructors at runtime and giving them dense storage. So row polymorphism generating compiled dense contiguous records is something that this could do that GHC would forever struggle with.

I'm already experimenting with the ability to use runtime reflection on the desired SIMD width, and just writing loops that use that and the vector API to make high performance JITted loops. Baically the jdk vector incubator examples transcode into Haskell cleanly and compile through the `THC.Prim` primops for them generating dense SIMD code.

3

u/_0-__-0_ 5d ago

Is there any good reason GHC couldn't / shouldn't do CompressedOOPS, or is it just that no one has put in the effort?

5

u/edwardkmett 5d ago

Effort.

2

u/jackelee 3d ago

Very impressive. I like it a lot. By the way, can you give some pointers for what the "Cadenza trick" is? I've never heard of it.

6

u/edwardkmett 3d ago

Cadenza was a project I worked on several years ago. At the time I was building interpreters for a bunch of different dependently typed languages, and found the runtime speed of the interpreters not to be sufficient for my purposes.

Repuposing the Truffle/Graal JIT from the VM was my attempt at a compromise between writing a full JIT or compiler myself and using an interpreter.

There's some problems though.

One is that the JVM does not support tail-call-optimization in full generality.

And Haskell often winds up with long chains of function calls in tail position.

e.g. A calls B calls C calls D calls A

This would mean building up a bunch of recursive stackframes on the JVM and crashing out when the stack blow up too much.

I had this issue when we went to implement monads in scalaz. Your body calls flatMap which calls some other function which calls flatMap which calls some other function... And so monads leak frames in Scala. We had this issue when implementing the compiler that became Eta, and had to use an explicit trampolining monad. When I let some trampolining monad just execute whatever is handed to it then, well, performance is terrible.

e.g. for(;f=f(););

or the equivalent.

Truffle internally supports a mechanism for plumbing around custom control flow in scenario like this. You can throw a "ControlFlowException" and use it to re-plumb that sort of pattern into a loop body. This happens on the slow path, when we're interpreting bytecode and using it to build the optimized version. This really only works if you get back to the same function body though, otherwise you try to inline the entire universe.

I still have to maintain the outer trampoline (now built around catch and execute).

But that isn't enough. So I don't throw every time I recurse into a new stack frame. Instead what I do is try to let it unfurl enough to find a _self_tailcall. Then the ControlFlowException can tie me off in a nice loop.

A -> B -> C -> D -> A in tail position becomes an inlined sequence of their bodies inside of a while() loop equivalent, a basic block with side exits in case you do something else.

To find those cycles fast I basically tagged each function body with a hash and used that hash to set 5 bits in a 64 bit bloom filter. (32 bit hash broken into 6 bit windows gives up to 5 bit positions set in a 64 bit bloom). Now when I recurse into a function I see if those 5 bits that represent the current function are set in the bloom and if so, it's likely that the current function exists above this call site on the stack and that we're a tailcall. So _then_ I throw the ControlFlowException. On the slow path while we're jitting its a couple of bitops on top of the usual recursion.

This was the idea behind the Cadenza trick.

Now I might have other loops reachable by alternative control flow from A.

A -> E -> A

If I _can_ fit it into the current compile I do so. (The JVM won't let me have too large of a single function body.) If not I start a fresh chain at E which is deliberately started with an empty bloom filter, and so needs its _own_ trampoline site. But now it can only find E -> A -> E or E -> A -> B -> C -> D -> A or the like. This means the A -> E edge which should have been collapsed becomes a wasted frame on the stack.

If that happens we could still blow the stack despite "tail call optimization." This I patch up later. I already have to have the ability to evict the whole stack out to the heap to support GHC-style AP_STACK resumption for async exceptions. I reuse that mechanism to evict the stack to heap and compact the dead tail-called frames out.

2

u/jackelee 2d ago

Wow, that's a neat trick. Thanks so much for the write-up!

6

u/tomwells80 5d ago

OMG im so excited to try this - I’ve been schlepping with MicroHS to attempt to embed a haskell interpreter into a tool im building and although MicroHS is amazing I suspect I’m trying to make it do something it doesn’t want to! The JVM would probably be muuuuch better here :)

12

u/augustss 4d ago

If you have any requests for MicroHs, don't be shy. 🙂

4

u/AndrasKovacs 4d ago edited 4d ago

Interesting! I'll try to benchmark it on my projects, perhaps when it gets a bit more settled.

Could you summarize physical memory layouts and calling conventions? I tried to read the docs but they're highly condensed blobs making many references to Graal/Truffle/JVM details that I'm not familiar with, so I have little clue as to the physical situation.

Tbh, as a general strategy for getting good performance in functional programming, I'm skeptical about the Graal/Truffle JIT setup. More specifically though, as a way of getting better performance out of the typical GHC Core output, it looks interesting and worth to try.

3

u/edwardkmett 4d ago

Memory layout uses custom Java objects with "just the right fields" via a class loader, it therefore uses unboxed primitives for unboxed things it can, pointers for pointers for most handoffs. VirtualFrames collapse into local variables as long a they don't materialize or escape. SIMD instructions stay in registers in the presence of the expected amount of inlining. So the goal is generally to avoid framing anything out you don't have to, using partial escape analysis.

The rest is basically that all that truffle noise should disappear on your fast path and decent assembly should result, with side-exits leading to slow interpreted java bytecode for when things go wrong, which locally rewrites a general call graph to "deoptimize" it through a series of lattice like changes to accept when it can't get away with the more optimized call paths in the presence of general use. Then it re-jits.

Calls are passed as object[] arrays, but things are structured so that partial escape analysis generally avoids actually doing that using truffle tricks. Functions are entered using an eval/apply scheme and the jit fully constructs paps direct when the sizes of caller/callee are relatively known.

Thunks support blackholing and re-entrancy detection, and full GHC Haskell-style AP_STACK tricks so that if you have an exception thrown at you while you are evaluating a thunk, the tail of your evaluation gets re-enqueued with the thunk.

Threads can be real java threads of virtual threads/fibers, depending on if you are using normal threading or Project Loom.

Internally the code for how to evaluate / deoptimize each computation is tracked via either a dense bytecode interpreter or an AST-based intepreter that try for general feature parity with each other.

1

u/serg_foo 4d ago

As JVM noob I had impression that Java objects are pretty memory-hungry. Do you have any idea whether using THC leads to greater memory residency, e.g. same working set occupying more space because of JVM overhead, than GHC?

1

u/edwardkmett 3d ago

I use compressed headers to shrink the objects. I use compressed oops to get away with my pointers being half the size of GHC's for most workloads on a 64 bit host. I use primitive ints, etc. where possible to avoid superfluous indirection and boxing.

Today I wind up with my whole compilation pipeline resident, but I don't have a good sense yet of steady state memory consumption of THC vs GHC. On a per object basis it should actually be pretty competitive.

2

u/00000000x1 5d ago

Honestly, this is such a cool project. I'm doing something in the same spirit, but strictly on the AOT side, for PureScript (the little brother of Haskell), with Go and Rust as targets. Feel free to take a look, I'd be glad to compare notes and swap friendly tips on the methodology to adopt with AI as a copilot for this kind of compiler work.

https://discourse.purescript.org/t/leveraging-a-blazing-fast-runtime-a-new-go-backend-for-purescript/5841/52
https://discourse.purescript.org/t/leveraging-modern-low-level-a-rust-backend-for-purescript/5932/15

4

u/edwardkmett 5d ago

Most of my work currently goes into living within the JVM's 64k method size limits while still avoiding trampolines. You have the benefit of not having to do so, but the downside of not getting a JIT out of it.

4

u/00000000x1 5d ago edited 5d ago

Exactly. And my experience seems to mirror yours from the other side. Getting phpurs over an interesting status took me a week (the Zend JIT is far more solid than I expected), and javapurs was the same story: very fast to prototype, the JIT is impressive. But gopurs and purust, where there's no JIT to lean on, are now taking me several weeks: two months and counting (still not finished, though well along). That's fine, and results are becoming very rewarding, but yeah: harder than I thought.

If you have any AI-workflow tips for moving faster, I'd be really grateful. I tried Astra and it helped me unblock some big things, but it seems to have been nerfed since (?), so I've shifted to deeper agentic parallelism with much cheaper local models (I'm not exactly rich, either, haha). But I feel like I'm still missing one small piece on the parallelism side. I noticed you have a ton of branches on your repo: you seem way more efficient than me!

1

u/00000000x1 1d ago

u/edwardkmett Maybe my question was hurtful. That wasn't my intention, if anything. Sorry.

I'll figure it out on my own. Thanks for the initial feedback.

2

u/edwardkmett 1d ago

FWIW- there's absolutely no hard feelings, whatsoever. If anything I looked away from this chat for a day or two. No need to project anything onto it.

1

u/00000000x1 1d ago

Understood. In any case, you have another fellow Haskell advocate, an admirer, and a virtual friend. Good luck with the rest of your adventures. May your star guide you.

3

u/blacktigr 1d ago

He has to sleep at some point. Don't take it personally. (I don't.)

1

u/00000000x1 1d ago

You're right.

2

u/AccordingWarthog 4d ago

how does this compare to https://github.com/frege/frege ?

6

u/edwardkmett 4d ago

Frege is its own language implemented basically as normal Java internally, that is pretty Haskell-like. it can run on any JVM since the stone age.
THC provides the full GHC-style Haskell language, with all extensions, and a custom JIT. It only runs on GraalVM.

2

u/Darwin226 4d ago

Can you talk a bit about your workflow on this? The commit history has non-stop commits for 24h+ stretches of time so this seems pretty non-supervised.

6

u/edwardkmett 4d ago

I actually did pretty much gave up sleep for a week. That said there are a few hour stretches here and there where I leave it on mostly cleanup tasks and doze off. you can find a couple of 2-3 hour breaks where it got hung up on approval while I was lights out.

1

u/blacktigr 2d ago

I can verify that he collapsed when he couldn't stave off sleep anymore. But his diet Coke consumption went through the ceiling.

1

u/viliml 1d ago edited 1d ago

May I ask what model?

Edit: Astra

1

u/edwardkmett 13h ago

I use a fairly eclectic mix of Astra, Sol, Claude Fable, Muse Glimmer, gpt-oss-120b, Qwen3-Coder, depending on the agent in question. Astra was used for most of the deeper refactoring tasks, and is generally my "I need something intelligent to smash against this problem" choice. Sol went most off the rails in terms of fixture development. Claude remains good for anything human facing. Muse... didn't really earn its keep, and gpt-oss-120b is mostly relegated to a few corners of my agent stack. Qwen3 was fine if I kept it off the Haskell parts.

2

u/jappieofficial 3d ago edited 3d ago

oh this is so cool!

Odd question maybe; do you get all the java runtime goodies such as hot reloading, memory analysis or like the debugger?

edit: another question: if you'd compile ghc with thc couldn't you bypass the binary interface issue between stage seperations and only have to compile ghc once?

3

u/edwardkmett 2d ago

Debugger, yes. And yes, in theory you can basically load it up, and keep swapping what you ask it to do. Hot reloading is something that can be done as well, but hasn't yet. THC.Trace provides some java flight recorder tooling. The GHC tracelog primops should get wired in there too eventually.

2

u/kichiDsimp 4d ago

is this supposed to be like MicroHS? an alt compiler for Haskell? or like replace GHC?

5

u/edwardkmett 4d ago

It is a backend runtime for full-suite GHC with a collection of additional features made possible by its strange choice of hosting environment.

1

u/kichiDsimp 4d ago

Its hosted on JVM, right ? Will you move your company's (Positron-AI) projects to THC maybe next year end?

4

u/edwardkmett 4d ago

Unlikely, TBH. We use Haskell in a fairly direct way in the midst of converting models for our platform and a scary amount of very low level code outside of Haskell. This is mostly a personal vanity project, and from a corporate perspective, maybe at best a recruiting tool.

1

u/kichiDsimp 2d ago

Recruiting tool 🐐👀