Thank you for opening the first community contributor PR to purs-wasm!
It’s also really exciting to hear that you’re already using it in your PureScript projects — that means a lot ![]()
I’ve merged the PR. Thanks again, and I’m looking forward to seeing more feedback and contributions in the future!![]()
No problem! It’s truly an honor to be part of the first community contribution on such a cool/necessary project.
Here’s where I used it this week: purescript-sha3/src at wasm · rowtype-yoga/purescript-sha3 · GitHub
Quick digression:
I’ve been implementing the SHA3 algorithm to help me learn about alternative back ends and benchmark them against each other. It’s nice to have those NIST test vectors to verify against. I personally think that it’s important for Purescript to grow beyond the restrictions of JavaScript even though it may never truly escape it. WASM is a huge step in that direction…as well as the Chez Scheme backend and the others I haven’t yet tried. The racket version of the compiler is another one that piques my interest. THAT’s how good metaprogramming looks.
Thank you! Getting benchmark results from a real, practical implementation like SHA3 — rather than a toy project — and seeing purs-wasm used and compared against other backends is incredibly concrete and valuable feedback for me.
I’m happy to see that the no-FFI version is already about 7× faster than JS.
At the same time, the reality that the Chez native FFI version is still nearly 50× faster is honestly motivating as well. Surpassing that may be difficult, but I definitely want to keep pushing to close the gap.
As for obvious areas where purs-wasm still has room to grow, support for things like Int64 and specialized containers such as I32Array / F64Array look like promising directions that we can probably experiment with relatively soon.
(Int64, I32Array, and F64Array in base)
And a branch where I added both as well as a bootstrap command/shellhook to prevent non-deterministic behavior for anyone that would clone this repo. It is now fully tested and is verified with no regression from the snapshots benchmark:
My personal goal is to get that SHA3 algorithm as close as possible to the Chez-Scheme benchmark by end of next week. According to my estimates, Purescript-wasm can get to (at best) 50% of chez-scheme backend’s performance (thank you, Cisco) but probably not better than that. ![]()
I added some more primitives and low-hanging fruit in my forks to squeeze some more performance out of WASM. Basically, I’ll end up writing four different pull requests (maybe I can compress it into two):
I ended up achieving my goal for the week of getting SHA3 to run a solid benchmark against the other backends. Nothing beats Chez-Scheme’s 60.6 MB/s but purs-wasm comes super close to native NodeJS speeds.
That’s a really exciting benchmark result! I’m honestly surprised to see Node + native FFI already within reach for us.
Also, thank you for continuing to send PRs — I really appreciate it!
Unfortunately, work has been keeping me a bit busier lately, so I haven’t been able to dedicate as much time to purs-wasm development as before. I’ll get to reviewing and merging them soon, so please bear with me for a little while.
P.S.
Recently, alongside purs-wasm development, I’ve started working toward another ambitious idea again.
That one might catch your interest too ![]()
This is really exciting work!
I’m planning to try running these benchmarks against your backend myself at some point to see if it supports all the test cases, once mature. However, please feel free to beat me to it and open a PR or an issue on the repo if you’d like!
I read an article yesterday about how Zig bootstrapped their compiler with WASM. Super interesting. They basically recreated their C/C++ compiler in WASI so they could have a device agnostic compiler/compiler VM.
https://ziglang.org/news/goodbye-cpp/
Compile the compiler to a simple virtual machine - Occasionally, I talk shop with Drew DeVault since he’s working on Hare. We started chatting about compiler bootstrapping and he mentioned OCaml’s strategy off the cuff:
andrewrk: do you care about the “stage 0” bootstrapping process?
ddevault: marginally
andrewrk: idea is to start with literally only a text editor and get up and running
ddevault: text editor? pfft! back in my day we used a magnetized needle and a steady hand
andrewrk: classic xkcd
andrewrk: did I say text editor? I meant hex editor
ddevault: anyway, there’s a bootstrapping path taken by, uh, ocaml I think
ddevault: which is kind of interesting
ddevault: they have a backend for a tiny VM target
ddevault: you implement that VM on your desired platform and you can then compile ocaml proper
andrewrk: ooooooh
This got my creative juices flowing. A bespoke backend just for bootstrapping is a neat idea, but I think the sweet spot for Zig looks different than what it does for OCaml.
There is exactly one VM target available to Zig that is both OS-agnostic and subject to LLVM’s state-of-the-art optimization passes, and that is WebAssembly, using WASI as the operating system abstraction layer.
Absolutely fascinating. I’m wondering if/dreaming whether this technique might be somehow relevant to Purescript despite our differences with OCaml and Zig.
Hey @katsujukou and @harryprayiv,
I’ve been following your progress on purs-wasm with great interest, and it’s truly fantastic work. Since we are both tackling the unique challenges of building AOT (Ahead-Of-Time) backends for PureScript, I wanted to share a recent architectural breakthrough I had with gopurs that might directly help with the “typeclass dictionary elimination” and “primitive inlining” optimizations you mentioned.
Like you, I initially built my backend consuming standard CoreFn. But I quickly hit a hard performance ceiling: because CoreFn strips away static type information, the backend is forced into dynamic boxing and heavy runtime checks, which severely limits what an AOT compiler can optimize.
To break this ceiling, I created a fork of the compiler to emit a Typed AST (tcorefn: essentially CoreFn + dataDecls array + ann.type on every single node). Having the strict type available at compile time changed everything:
- It allowed the backend to completely eliminate primitive boxing.
- It made it possible to translate typeclass dictionaries directly into native, statically-typed structs (zero-cost abstraction).
This single architectural shift resulted in a huge performance jump, putting the generated Go code on par with V8 on a single thread (at least, because I still work on optimizations). I detailed the process and the benchmark results in my devlog here:
Given how strict WebAssembly is with primitive types (i32, i64, f64), I imagine having access to a fully typed AST would allow purs-wasm to emit native instructions directly without relying on workarounds, and finally solve the dictionary elimination problem?
I am currently preparing a PR to the official PureScript compiler to add --codegen tcorefn as a standard option. I’d love to hear your thoughts on this. If tcorefn could also unlock WebAssembly’s true speed, I think a friendly “coalition” of AOT backends pushing for this feature would be a huge step forward for the whole ecosystem.
Feel free to let me know if you’d like some help exploring TAST for the Wasm project. I’d be more than happy to jump in, or even run some tests on my end to see if it brings a tangible performance gain for WebAssembly.
Keep up the amazing work!
I am currently preparing a PR to the official PureScript compiler to add
--codegen tcorefnas a standard option.
Sounds great!
I built a Haskell tool for generating a compressed context of a codebase for the sake of an LLM’s limited context window (currently works with Unison, Haskell, and Purescript). GitHub - harryprayiv/chase at unison · GitHub
The naive implementation of Purescript part relied heavily on ‘’CoreFn’’. But I reached a context window just as you did. But instead of extending Purescript, I reached for the actual Purescript CST parser and vendored that. I’m pretty sure that the context I could provide would improve dramatically with TAST’s and I could go back to a less hacky method here using them.
That’s great: one more reason to propose this PR. I’ll let you know how it goes!
Before things possibly become more official, I thought it might be helpful for you to get an idea of how TASTs are generated: here is the main update.
@harryprayiv You could even try them with chase ![]()
I’ll probably do a test against the actual Purescript compiler that I currently vendor. Do you think TAST’s offer advantages there? I ended up using the actual Purescript compiler so I can’t imagine I can get even more type information since I wasn’t going the CoreFn route (since I found it as restrictive as you). Since Chase is proper Haskell it was pretty easy to just use the Purescript compiler and have it roll along with my little tool.
As far as WASM. I think you should try it in a fork to perhaps demonstrate the viability. WASM is already easy to keep in sync with thanks to the Nix lock files. So, you can just fork it and roll with the changes with no worries (as I did when I was trying to squeeze some performance out of it).
Regarding chase: I think TAST probably wouldn’t be a full replacement for the CST parser, but rather a complementary tool. While the CST gives you the exact high-level syntax (including typeclass constraints like Show a =>), TAST gives you the implicit and deep semantic context. By using tcorefn (or the enriched corefn in the fork), you get the resolved, desugared, inferred type for every single intermediate expression (ann.type), as well as the exact memory layout of ADTs (dataDecls). It simplifies the extraction of semantic data, saving you from having to guess or resolve types manually. E.g. if a developer omits a type signature: you won’t have it in the CST, but you will, in the TAST. But it’s also more compiler-oriented, and you lose human intention, a bit. It’s like parsing old Haskell: you get explicit dictionary records, etc. So it’s useful when you want that. It’s less useful when you want to have a sense of what the developer wanted to express, in a very high-level sense.
Regarding the WASM project: great idea! I am going to evaluate the repository and see what kind of simple and effective POC I can put together. Thanks for the suggestion and the tip about Nix lock files, it definitely makes things easier to test without breaking everything.
I’ll keep you posted!
(post deleted by author)
That being said, we could absolutely enrich TAST to include these human intentions as well! The compiler actually holds the full SourceType (which includes ForAll quantifiers and typeclass constraints), before I deliberately strip them out in simplifyType to generate the structural tcorefn for AOT compilation. If needed for LLM context, it would be quite trivial to expose a TAST++ that serializes this high-level constraint information directly into the JSON.
We would add ann.sourceType (conceptually similar to a sourceMap) alongside the simplified type. For example, a function like foo :: forall a. Show a => a -> String would look like this (simplified):
"ann": {
"sourceSpan": { ... },
"type": {
"Func": {
"args": [
{ "Record": { "show": { "Func": { "args": [{ "TypeVar": "a" }], "ret": "String" } } } },
{ "TypeVar": "a" }
],
"ret": "String"
}
// ^ Perfect for AOT: simplified and desugared (constraints are explicitly dictionary arguments)
},
"sourceType": {
"ForAll": {
"identifier": "a",
"type": {
"ConstrainedType": {
"constraint": { "className": ["Prim", "Show"] },
"type": {
"Func": {
"args": [ { "TypeVar": "a" } ],
"ret": { "TypeConstructor": ["Prim", "String"] }
}
}
}
}
}
// ^ Perfect for Tooling/LLM: preserves the exact human intention `Show a => a -> String`
}
}
Regarding the human coder, the CST is for what’s explicitly written, and the TAST is for what’s (explicitly and) implicitly written (made explicit). A LLM can be interested in both, depending on the subject.
Since this might be off-topic for WASM (perhaps), feel free to open an issue in my Purescript fork. We’ll put our thoughts there!
–––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––––
Edit (the platform allows a maximum of 3 consecutive posts):
Hey @katsujukou & @harryprayiv,
As promised, I took some time to experiment with the Wasm backend to see if injecting the TAST metadata could help solve some of the optimization ceilings. I’ve put together a very simple, minimalist, hacky Proof of Concept (POC) on a fork, and the results are honestly quite good.
To test it, I targeted the “unboxing” heuristic. Currently, the backend uses counts.boxed <= 1 to guess if an Int can be safely unboxed as an I32. However, if that Int is used in a polymorphic context (even inside a dead or cold branch), the solver conservatively assigns it a Bx (Boxed) representation globally. This penalizes the hot path with multiple unnecessary allocations.
By propagating the ExprType metadata from my tcorefn fork all the way down to the MIR NonRec bindings, the compiler no longer has to guess. It strictly knows the variable is an Int. More importantly, by combining this absolute truth with a quick loop analysis (inLoop), we can forcefully assign Ti32 in hot paths while still falling back to the memory-saving heuristic for purely flat/linear code!
I wrote a small polyInt benchmark (a tight recursive loop of 6.4 million iterations holding an accumulator, but with a dead branch forcing polymorphic usage) to measure the impact:
Benchmark results (polyInt - 6.4M iterations):
-
Without TAST (current
counts.boxedheuristic): ~12.9ms (suffers from 1 allocation per loop iteration) -
With TAST (forced
I32fast-path): ~3.1ms (0 allocations in the hot path)
This structural proof yields a ~3.5x performance boost in scenarios that previously confused the heuristic solver.
Please note: this fork is not a clean, mergeable PR right now (it’s not nix-ified, the pattern matching updates across the MIR passes are a bit rough, etc.). It’s strictly a minimalist POC designed to prove the viability and the massive potential of having strict type information available for AOT compilation.
I just focused on one obvious improvement, but there are definitely many more things we could explore with this TAST approach:
- Zero-cost Typeclass Dictionaries: Statically resolving properties at compile time and eliminating boxed dictionary records completely (mapping them to native Wasm structs with unboxed fields).
-
Fully Unboxed ADTs: Using the
dataDeclsfrom the TAST to strictly map memory layouts and avoid boxing ADT payloads. -
Eager Uncurrying: Knowing the exact arity of functions everywhere to avoid
$Cloallocations altogether. - etc.
Also, this TAST-guided unboxing approach may be preferable to aggressive monomorphization for WebAssembly. It allows us to reap the benefits of native primitive speeds locally, without duplicating polymorphic functions, which keeps the final .wasm binaries lightweight.
If you’re curious, you can check out the POC commit here.
If this direction is promising for the future of purs-wasm, I’d be happy to discuss how we could make this structural approach cleaner and more robust together. Or, if you prefer, I would be more than happy to simply pass the torch and let you evolve independently with this approach, which I will have had the pleasure of bringing to your attention!
Cheers ![]()
A clean, self-contained PR is the fastest path here
@katsujukou is responsive and will almost certainly give it a look.
One thing I want to make sure I’m understanding correctly: doesn’t this WASM PR need a PR landing in Purescript for TAST’s first to enable it? From here it looks like a number of components can only be built on your forks, so I may be missing a step.
For what it’s worth, even the comparatively modest changes I made to the WASM backend needed a couple of rounds of cleanup in @katsujukou ’s capable hands before they were mergeable. It might be worth freezing the current scope and getting it landed, cleaned up, formalized, and extra rigorously tested before building more on top of it.
Yes exactly, this was just a quick POC to show you the potential, not meant to be merged as-is.
And you are entirely right: the --codegen tcorefn PR needs to land in the upstream PureScript compiler first to enable this. Since I was just testing the waters with this POC, I haven’t pushed that upstream PR yet, but it’s on its way (once I’ve sufficiently battle-tested it against gopurs, etc.).
Of course, I’ll do things the way you suggested, which is much healthier.
