WebAssembly Backend Redux!

@harryprayiv

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!

1 Like

(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.boxed heuristic): ~12.9ms (suffers from 1 allocation per loop iteration)
  • With TAST (forced I32 fast-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 dataDecls from the TAST to strictly map memory layouts and avoid boxing ADT payloads.
  • Eager Uncurrying: Knowing the exact arity of functions everywhere to avoid $Clo allocations 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 :v:

1 Like

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.

1 Like

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.

1 Like