Leveraging a blazing-fast runtime: a (new) Go backend for PureScript

News #8:

Aff is now fully supported! :tada:

To achieve this, we’re diving headfirst into goroutines. Of course, primitives like AVar and Ref now include thread-safeguards that classic JS Aff didn’t need to implement. This isn’t an actor model like purerl, nor is it a hack to mimic a single-threaded Event Loop: Go introduces true, shared-memory asynchronous parallelism, rather than just single-threaded concurrency.

But from a DX perspective? It’s still the straightforward Aff you know and love. The underlying parallelism simply boosts performance behind the scenes.

Another major milestone has just been reached for gopurs: the unit tests for a real, full-scale project are now passing (259/259 green tests :white_check_mark:). Integration tests will be my next step, but this validates a significant portion of what has been implemented so far, especially since the tests themselves are formidable Aff nesting dolls (combining Spec, Aff, AVar, and complex FFI). You can see how it looks here: I swap from JS to PHP, then from PHP to Go.

The project is still a mess, but I’m staying focused on the results. I’ll make it maintainable down the line, when we’re ready to get this project running at a steady pace. I’ll keep you posted (e.g. Aff benchmarks).

:v:

Note: @harryprayiv I haven’t forgotten you, and I’m working on a reply for you!

Hi @harryprayiv

Regarding your question about the Value vs TAST paradox and the 240ms benchmark: your intuition was absolutely right.

In that specific micro-benchmark, the Go compiler was able to heavily optimize the tight loop (likely through aggressive inlining and branch prediction), which artificially brought the Value (tagged union) approach up to par with static Go.

However, as soon as we step out of these simple algorithms and run real-world, complex code (with deep ADTs, nested Records, and higher-order functions), the cumulative overhead of runtime tag checks and the limitations it imposes on the Go compiler become a real bottleneck too, even it’s still far better than using any everywhere. This is the first “ceiling” I hit. Value is a fantastic way to avoid boxing and GC pressure, but it cannot truly match native performance at scale.

This is exactly why TAST (and partial monomorphization) were the real game changers here. They aren’t meant to optimize simple loops (where Value already shines), but to break that ceiling on complex code.

To avoid any confusion for future readers, I have edited my original post regarding the 240/250ms benchmark.

I’ve also updated the benchmark links across my previous posts to ensure they accurately reflect the historical state of those experiments (for instance, pointing to the exact snapshots where gopurs was hitting >100ms rather than the current ~67ms). Before TASTs, the best I could get was 1000ms. Yes… more than 1 second… At that point, I’d had 2 or 3 very very short nights in a row, and I had even almost given up on the project and on the hope of creating an effective compilation…

Once the project is mature, all the information will be included in the README.md file.

Thanks again for your sharp and constructive feedback. :pray:

(and as for the TAST improvements, I’m just getting started on them)

1 Like

News #9:

FFI & DX: a Wasm AST Parser :mage:

One of the pain points with FFI in alternative backends is the boilerplate (manual boxing/unboxing, runtime dependencies). With gopurs, I wanted FFI to feel 100% native for Go developers.

To achieve this, the compiler uses a Go AST parser compiled to WebAssembly (ffi_gen.wasm) that analyzes your .go FFI files on the fly. It reads your Go signatures and automatically generates the perfect, highly-optimized bridging code.

1. Uncurrying & idiomatic native types

I also wanted to avoid the hassle of currying. In modern JS (ES6+), writing a => b => a + b is highly ergonomic. But in Go, manual currying quickly becomes a nightmare of nested func(interface{}) interface{} closures.

By analyzing your signatures, the Wasm parser allows you to write perfectly flat and strongly typed Go functions:

Before (Typical FFI boilerplate with manual currying & boxing):

func DoMath(arg0 gopurs_runtime.Value) gopurs_runtime.Value {
    return gopurs_runtime.Func(func(arg1 gopurs_runtime.Value) gopurs_runtime.Value {
        a := gopurs_runtime.Unbox[int64](arg0)
        b := gopurs_runtime.Unbox[int64](arg1)
        return gopurs_runtime.Box(a + b)
    })
}

After (gopurs FFI):

func DoMath(a int64, b int64) interface{} {
    return a + b
}

You just write pure Go. The generated bridge takes care of all the uncurrying and type conversions under the hood.

2. Flexible Effect semantics

In PureScript, Effect delays execution. Thanks to the Wasm parser, you can choose exactly how you want your native Go effects to behave, and the compiler will adapt the generated wrapper automatically:

Option A: Flattened (N+1 argument)
Perfect for simple, direct side-effects.

func Log(message string, _ interface{}) interface{} {
    fmt.Println(message)
    return nil
}

Option B: Curried (returning a closure)
Perfect for caching expensive pure work before the effect runs.

func MatchRegex(pattern string) func(interface{}) interface{} {
    compiled := regexp.MustCompile(pattern) // Evaluated ONCE!
    
    return func(_ interface{}) interface{} {
        return compiled.MatchString("...")  // Evaluated on Effect run
    }
}

The Wasm parser detects your Go function’s return type (func(...)) and automatically adapts the backend optimizer to preserve your partial application caching, without sacrificing the overall uncurrying performance.

Best of both worlds.

News #10:

Here we go!

gopurs can now emulate the behavior of the event loop with respect to “fibers”: the main thread will patiently wait for child threads to finish before automatically terminating itself. Just like our ol’ Node Event Loop pal, when it detects relevant child handles. No abrupt exits when your main() function returns while Aff tasks are still running in the background.

This was a major milestone to unlock the full potential of Aff in Go. And speaking of potential… I’ve just updated the extended benchmarks to include asynchronous operations, File I/O, and most importantly: parallelism. You can find the test here.

Since Go natively maps goroutines to multiple OS threads, gopurs implicitly transforms forkAff into true multi-core execution. The results on a heavily CPU-bound parallel workload, running 4 concurrent fib 42 computations (intentionally written in a naive, unoptimized recursive way to maximize CPU stress), speak for themselves:

Benchmark JS (V8) Arista ES (V8) Go (gopurs)
File I/O (10k writes/reads) ~ 401 ms ~ 442 ms ~ 421 ms
Aff Ops (Async Delays) ~ 11 ms ~ 10 ms ~ 11 ms
Parallelism (4x Fib 42) ~ 5863 ms ~ 6028 ms ~ 1181 ms :rocket:
Total Execution Time ~ 6403 ms ~ 6560 ms ~ 1690 ms

Kapture 2026-08-02 at 20.35.22

JS and Arista run Fib 42 sequentially on a single thread blocking the event loop, while Go successfully distributes the 4 heavy tasks across 4 different CPU cores, making it 4x faster for this specific workload).

Of course, the more cores you have, the wider the gap becomes. For example, testing the limits on a local machine with 10 cores (10 concurrent tasks), at home:

Benchmark JS (V8) Arista ES (V8) Go (gopurs)
Parallelism (10x Fib 42) ~ 15113 ms ~ 14690 ms ~ 1255 ms :rocket:

JS and Arista scale linearly and predictably choke, while Go devours the 10 tasks in roughly the same time it took for 4, making it ~12x faster. As you can see, Go’s performance has remained almost unchanged! There may only be a slight additional overhead due to orchestration (more children to manage).


It’s incredibly satisfying to write standard, pure PureScript code with Aff and forkAff, and get true, unboxed multi-threading entirely for free on the backend. The perf gain is awesome. This is quite similar to what TS devs have seen in their comptime with v7 (their recent game-changing major release). But here, that’s pure runtime progress.

A summary is available here.

Next step: put gopurs through its paces with integration tests (Postgres, S3, RabbitMQ…), complete the core lib FFIs (e.g. parAff), etc.

1 Like

News #11:

100% green on the integration tests of b8x, but more importantly on the unit tests of Aff (gopurs-aff, mirror of purescript-aff) :white_check_mark: :tada:

Aff is one of the most complex and critical core libraries in the PureScript ecosystem. It handles asynchronous fibers, AVars, intricate error handling (bracket), cancellation, parallel execution (parAff), etc.

Getting all these tests to pass proves that the Go implementation (which relies natively on true multi-threading via goroutines and shared memory) is not just blazing fast, but semantically sound and strictly correct. We’ve successfully mapped the subtle edge cases of JS cooperative concurrency to Go’s parallel execution model without breaking the API or the expected behaviors.

To be even more precise: I found that a few original tests were actually somewhat “tainted” by the JS Event Loop conceptual model. They implicitly relied on synchronous execution happening before encountering the first blocking handler (which leaked in the assertions). In a true parallel environment like Go, where forkAff spawns an actual concurrent goroutine instantly, these hidden race conditions were immediately exposed. We had to slightly adjust those tests to be truly async-safe, which further proves the strictness and robustness of this new parallel foundation. Example: before and after.

Aff is now a pure abstraction, decoupled from the historical JS runtime.

Next steps: gopurs-arrays, gopurs-assert, etc.

:v:

News #12:

Finally done with the existing official tests for each module. They are all green now. :green_circle:


E.g. tests are passing for gopurs-arrays (Go-FFIed mirror of purescript-arrays). Of course, the PureScript code does not need to change, in your project(s). The signatures of all functions has been kept intact. The single thing that changes is the lib source in spago.yaml (e.g. Data.Array will target gopurs-arrays, not purescript-arrays).

I’m going to go back through them now and improve their coverage, by adding non-official tests. But the bulk of this looong work is behind me.

List of validated modules
  1. gopurs-argonaut-core

  2. gopurs-arrays

  3. gopurs-assert

  4. gopurs-avar

  5. gopurs-catenable-lists

  6. gopurs-console

  7. gopurs-datetime

  8. gopurs-effect

  9. gopurs-enums

  10. gopurs-exceptions

  11. gopurs-foldable-traversable

  12. gopurs-foreign

  13. gopurs-foreign-object

  14. gopurs-free

  15. gopurs-functions

  16. gopurs-integers

  17. gopurs-js-bigints

  18. gopurs-js-date

  19. gopurs-js-promise

  20. gopurs-js-promise-aff

  21. gopurs-js-uri

  22. gopurs-lazy

  23. gopurs-node-buffer

  24. gopurs-node-event-emitter

  25. gopurs-node-fs

  26. gopurs-node-http

  27. gopurs-node-net

  28. gopurs-node-path

  29. gopurs-node-process

  30. gopurs-node-streams

  31. gopurs-now

  32. gopurs-nullable

  33. gopurs-numbers

  34. gopurs-ordered-collections

  35. gopurs-partial

  36. gopurs-prelude

  37. gopurs-quickcheck

  38. gopurs-random

  39. gopurs-record

  40. gopurs-refs

  41. gopurs-run

  42. gopurs-simple-json

  43. gopurs-spec

  44. gopurs-st

  45. gopurs-strings

  46. gopurs-strings-extra

  47. gopurs-unfoldable

  48. gopurs-unsafe-coerce

  49. gopurs-uuid

  50. gopurs-variant

  51. gopurs-yoga-json

Of course, other modules will be supported in the future (feel free to open a PR if needed).

@harryprayiv Opened a dedicated thread for TASTs :slightly_smiling_face:

1 Like

News #13:

I’ve recently added new comparisons to the stress tests, measuring the performance of code generated by our backends against fully hand-optimized, imperative native equivalents (using the Native FFI).

We are consistently observing a ~2x performance factor between the compiled PureScript code and the raw native implementations. This is quite an interesting threshold.

When you consider the Arista ES backend (which features advanced optimizations for JS) and our own gopurs backend, both seem to plateau around this exact same overhead ratio. Having spent significant time trying to squeeze out a few more milliseconds from the Go compiler myself, and facing increasing difficulty doing so, it strongly suggests we might be reaching the hard limits of optimization here. There is an inherent, irreducible cost to maintaining high-level functional abstractions (like deep closures, persistent immutability, and recursion) on targets that aren’t purely designed for them.

But this is actually great news, because the factor is fine, and it brings us to a fundamental philosophy about how we should write and build applications:

These stress tests are deliberately unoptimized. They are designed as boundary cases to push the compilers to their absolute limits. In a real-world project, 99% of the codebase will perform remarkably close to native speeds.

This performance is more than enough for the vast majority of our work, and it grants us the immense privilege of focusing entirely on our high-level domain concepts, safely ignoring the low-level machinery details. For the remaining 1% (those critical algorithmic hot paths) we have three options: accept a 2x slowdown, use safe mutability abstractions like the ST monad (which compile down to highly efficient imperative loops), or seamlessly drop down into FFI to write raw, imperative code as close to the metal as needed.

At this stage, optimization gains are often on the scale of mere microseconds. While further improvement is always possible, chasing these micro-optimizations is no longer a priority for me, right now.

Just one exception: Go 1.27 has been released . Huge improvements, especially around generics and polymorphism. That could unlock further optimizations for gopurs. I’ll try that in the coming hours/days.

1 Like

News #14:

Good news. Got -10ms thanks to Go v1.27 (64ms54ms). Still things to do, but that reactivated the optimization step, which I thought was over.

News #15:

When I started to optimize gopurs, this was a goal that seemed difficult to achieve, but it has now become the new standard: we’ve almost matched Scheme’s performance on the benchmark! :checkered_flag:

Go gives less stable metrics than Scheme; the standard deviation is higher around the mean. But it’s still a real cause for satisfaction. Scheme is one of the go-to compilers for FP, one of the most powerful, with decades of optimizations under its belt. So this is really great news.

1 Like

I recently noticed that @i-am-the-slime has been actively maintaining and rescuing the purescript-native and go-ffi efforts (kudos for the incredible work and dedication by the way, it’s amazing to see fellow enthusiasts keeping this alive! I also saw that you managed to implement support for Aff on your side too, which is awesome :raised_hands:).

Since I’ve been working on my own experimental Go backend, I thought it would be interesting to run a side-by-side benchmark to see if my alternative approach still makes sense. I added the maintained psgo (the fork of purescript-native Go backend) to my stress-test benchmark suite to compare the two.

At first glance, the results seem to validate the new architectural choices made in gopurs (TAST, backend-optimizer, intensive use of goroutines, Go 1.27 features, etc.). By preserving deep type information, gopurs can generate statically-typed Go structs and avoid interface{} boxing/dynamic type assertions, which seems to yield a significant performance boost (especially on heavy polymorphic lookups and closures). The tests are 35x faster.

However, I really want to remain as objective and fair as possible. Feel free to tell me if you spot any issues with the benchmark setup for psgo in my repository (especially @i-am-the-slime or anyone familiar with the psgo internals). I want to make sure the comparison is perfectly balanced, and that I haven’t missed any optimization flags or introduced any unfair bottlenecks on the psgo side.

My goal is a collective progress, above all :slightly_smiling_face:

You can find the benchmark repository and the results here. The runner script for psgo is located here.

Cheers

News #16:

Go exhibited some of the highest variance during cold starts (initial runs could take 69 ms, dropping and stabilizing to 50 ms when repeated). I stabilized the benchmarks by introducing a warm-up phase before taking measurements. The results are now highly accurate and consistent.

I added further optimizations (I am currently working on a Worker/Wrapper pattern, to shave off the last few microseconds).

It’s completely trivial now, but still… I have to say: what follows really made me happy…

Go has overtaken Scheme for the first time, since I started working on gopurs a month ago.

It now ranks first among the backends tested in the benchmark. (In reality, fluctuations after the decimal point make the match very close on my local machine. But hey, I’m still really happy about it.)

:partyparrot:


Go:

Scheme:

1 Like

News #17:

An article has been written about gopurs. Hope it will find readers, for PureScript’s sake.

News #18:

Alright, so after pushing down the performance numbers on the benchmark (in a good way! ~44ms), there is one significant caveat left: the compiler is now working harder to achieve them. This is a classic trade-off, but when re-testing all of this on a real-world project, I ran into a few little surprises (e.g., RAM saturation).

Now, it’s time to focus on improving compile-time performance, an area I hadn’t spent much time on until now. It might be a less time-consuming task, but it’s just as essential.

To give you an example of how I’m tackling this: I’m now drawing heavy inspiration from things like Rust’s .rmeta files (we now have a .purmeta equivalent). The goal is to relieve both RAM and CPU. Instead of keeping the entire AST in memory, the RAM now only temporarily loads the specific chunks it needs from the disk, which acts as the main receptacle for memoization.

In short, fairly simple, but fundamental architectural changes.

News #19:

The project is now officially entering its cleanup and maintainability phase.

However, as I started diving deep into the compiler’s performance and architecture, I’ve been maturing a rather radical choice: rewriting the compiler backend in Rust (a temporary repo will evolve here). I’ve managed to completely avoid RAM overloads using a few tricks (.purmeta, etc.), but the further I go, the more I realize that there are things more important than that missing (see the list below).

Here is why I’m considering this pivot:

  • True parallelism: I want to fully leverage multi-core processing to parse and transform TAST files, etc. While we could theoretically bootstrap gopurs to compile itself and use Go’s concurrency, debugging a self-hosted AOT compiler that relies on its own generated code is a chicken-and-egg challenge I don’t really have the time to deal with, these days. Using a dedicated systems language like Rust gives us a direct and fearless parallelism out of the box.
  • Raw speed: I want the compilation times to be as fast as possible, even during single-threaded execution. E.g., Rust’s performance profile and insanely fast JSON deserialization are perfectly suited for crunching the tcorefn payloads.
  • Memory reliability: TAST never erases types, the AST graph we hold is really big. In GC-based environments, allocating millions of tiny AST nodes puts great pressure on the Garbage Collector. Rust allows us to bypass this entirely (e.g., using Arena Allocators), giving us a perfectly predictable, highly reliable, and performant memory model.
  • Natural translation: you might wonder if porting from PureScript to Rust means losing our expressive FP abstractions. Thankfully, the core of this compiler isn’t heavily reliant on advanced type-level programming. It is essentially a pure pipeline transforming massive ADTs. This maps flawlessly to Rust: it won’t require translating into complex traits or fighting the lack of HKTs. It just means writing a lot of enums and exhaustive match statements. I’m oversimplifying a bit, but it guarantees a smooth architectural transition.

I want to be transparent: this decision might delay the official release by at least a month. But I believe the long-term benefits for our ecosystem are well worth the wait.

Perhaps purust will reach a level of maturity where we can leverage it directly one day to write compilers with native parallelism. But as it stands, writing the tooling directly in Rust seems to be the most reasonable path to ensure the compiler is as performant as possible.

Note: a Purescript developer could certainly use Node.js for local development (no AOT compiling penalty), and Go or Rust for production, but my initial goal was a bit more ambitious than that: I’d like to enable a Purescript developer (who doesn’t work on frontend projects) to completely forget about Node.js and npm. Not out of any particular animosity toward this technology, but for the sake of convenience (only one runtime target, no prod surprise, etc.).

Note #2: purbo has been created, and it is a direct Rust port of @natefaubion’s brilliant purescript-backend-optimizer. All credit goes to him for the semantic architecture, this will just be a translation to leverage Rust’s parallelism and memory management. That is something gopurs will rely on, like other backends in the future.


Edit: A slightly more nuanced compromise was made. More details here.

News #20:

Oh… I was starting to doubt it, but in the end, the Wrapper/Worker pattern paid off in gopurs: we’re now down to 42ms… It really is a very strange feeling, like a never-ending logarithmic slide. But that’s a good thing. Benchmark updated here.

News #21:

I am considering that the best approach moving forward might be to have a coexistence of PureScript and Rust code within gopurs.

The idea would be to use Rust to compile the official, highly-performant release binary, while keeping a PureScript version to compile a dev binary and easily understand what the compiler does. This could ensure that the community can easily experiment, create proof of concepts, and submit PRs using PureScript. In this context, more time to compile is absolutely not a problem.

Nowadays, AI makes it more easy to “transpile” diffs if a contributor wants to patch the Rust mirror, without learning it in depth. (Of course, I could handle the Rust porting myself. Or purust could do this automatically in the future.)

Ultimately, gopurs is a tool for the PureScript community, and my goal is for it to remain maintainable by the community, not just by me. Rust would be there strictly for performance reasons (serde for JSON decoding, parallelism…), not to serve as the documentation medium for compilation methods. PureScript remains much more refined, concise, and its expressivity (which is one of its greatest qualities) makes it the perfect language for documenting and designing the compiler’s logic.

Also: currently testing the Scrutinee Fusion method (inspired by id3as and their purescript-backend-erl compiler).

News #22:

Latest work completed on Records, making the most of the power of Go struct-ures.

Deep Record Updates: 150 μs → 5 μs.

News #23:

A final optimization push has been made.

The gopurs-compiled code (~ 40.5 ms), which doesn’t hesitate to use every means at its disposal (e.g., deep monomorphization, stack-allocated structs, goto…), finally reached the symbolic milestone of being twice as fast as the Arista-compiled version (~ 81 ms), on single-thread tasks.

Just in case, I’m still going to try using gopurs.js soon to compile gopurs.go so I can use it as the official binary (and thus take advantage of its parallelism). Of course, Rust is cool and useful, but I’m still a little unsure about whether it really makes sense to use it right now. The debate is still in my mind…

News #24:

Another major milestone achieved: the total execution time on the core stress tests just dropped from ~40.5 ms to ~28 ms!

This massive 30% gain comes from a single, targeted compiler pass I just implemented, which I’d call “Thunk Fusion” (added a dedicated test for it in the official passing/ folder).

By deeply analyzing the TAST, gopurs can now detect when a Lazy thunk encapsulates pure, total integer arithmetic and is immediately forced by its consumer. When this precise pattern is matched, the compiler safely bypasses the dynamic closure allocation entirely and generates a strict, unboxed, tight loop in Go. It essentially synthesizes the “cheatcode” performance automatically.

Crucially, this does not break the Lazy contract or semantics:

  • It only applies to total operations (no side-effects, no risk of evaluating diverging effects prematurely).
  • It strictly requires an immediate consumer. If the Lazy value is passed around or stored, it gracefully falls back to a standard deferred closure.
  • Recursive LetRec blocks are explicitly excluded to maintain safe initialization semantics.
  • etc.

This perfectly embodies the AOT philosophy I was aiming for with this backend: letting the compiler cancel out the overhead of high-level functional abstractions (like abusing Lazy in a hot path) without forcing the developer to manually drop down to FFI or rewrite their code imperatively.

In doing so, the compiler becomes more and more forgiving of upfront design mistakes. A PureScript developer can write naive, unoptimized code (abusing closures or deferring execution unnecessarily) and the compiler will silently catch and clean up those architectural flaws behind the scenes, ensuring top-tier performance anyway.

I’m remaining cautious, of course. With optimizations this aggressive, there is always a risk of hitting unexpected edge cases in the wild. But the strict semantic guards I’ve put in place, along with the test suite passing flawlessly, give me more confidence.

We are now incredibly close to imperative and native Go (~27 ms).

Today is a good day. :sunflower: