Skip to content

feat(stdlib): Rewrite toString to use a layout tree - #2385

Open
spotandjake wants to merge 10 commits into
grain-lang:oscar/gc-rebasedfrom
spotandjake:spotandjake/toString
Open

feat(stdlib): Rewrite toString to use a layout tree#2385
spotandjake wants to merge 10 commits into
grain-lang:oscar/gc-rebasedfrom
spotandjake:spotandjake/toString

Conversation

@spotandjake

@spotandjake spotandjake commented Apr 29, 2026

Copy link
Copy Markdown
Member

This pr refactors toString to improve the formatting of printed items. Previously, we were naively concatenating strings while stringifying. While this approach was relatively performant and very compact, it meant we weren't able to support high-quality formatting akin to how we format grain code itself. In order to solve this problem, I ported the doc printing engine used in the formatter into Grain and rewrote our implementation of it.
Screenshot 2026-04-29 at 5 09 02 PM

More information on the implementation itself and design decisions can be found here: https://github.com/spotandjake/Grain-toString/tree/spotandjake/gc, along with some more in-depth notes on both the size and performance changes.

A few more notable things:

  • When testing as a library, I was only seeing about a 40% increase in the size of Hello World.
    • Our smallest grain module increased in size by about 100 bytes, looking into the wasm itself this seems to be related to the builders in doc.gr, these are capable of being optimized away however I don't think our current passes handle them very well.
  • Performance for small prints has stayed the same; however, it seems that when printing very long lists with around 750k items, performance degrades a little bit compared to before.
    • I tested this in wasmtime, and most of the time seemed to have been spent in gc. I think this is much less of an issue in v8 and will improve in wasmtime in the future.
    • Most of the time, users are going to be printing small items or singular large dumps when debugging. The performance I was seeing seemed very acceptable under these workloads.
  • This refactor adds a few very useful libraries to the runtime, doc, miniBuffer and vector, all of which are generic enough to be used elsewhere.
    • The vector library can be used in marshal getting rid of the second implementation there, it could also back a higher level vector library fairly easily.
    • The MiniBuffer library would be rather useful through the stdlib, notably things like List.join
  • While I did optimize the doc library for grain, I didn't change the api from the formatter; this means that we can use it in other places, such as improving JSON stringification in the future.
  • Each datatype exposes a separate printer, which will allow us to provide more specific implementations or, in the future, maybe have the compiler monomorphize things like print("Hello World").
  • Now instead of just printing unknown when we don't have type metadata for a variant or record we print the data inside and leave the fields or variant itself as unknown.

This work is based on: #2378

Closes: #2087
Work towards: #1794, #2127

@spotandjake spotandjake self-assigned this Apr 29, 2026
@spotandjake
spotandjake force-pushed the spotandjake/toString branch 3 times, most recently from c390917 to 84e6f27 Compare April 30, 2026 19:15
It seems that refmt removes extra whitespace in raw strings, which breaks our testing so I had to go back to using a slightly more ugly regular string.
I noticed that the builders being global allocations were causing some optimization issues, that can easily be avoided by turning them into functions. This has a slight performance drawback however it is used very sparingly so it should be reasonable.
@spotandjake
spotandjake force-pushed the spotandjake/toString branch from 84e6f27 to ccc535b Compare May 10, 2026 21:29

@Kara-Zor-El Kara-Zor-El left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great. some small nits

Comment thread stdlib/runtime/toString.gr Outdated
Comment thread stdlib/runtime/toString.gr
Comment thread stdlib/runtime/toString.gr
Comment thread stdlib/runtime/toString.gr
Comment thread stdlib/runtime/toString.gr
Comment thread stdlib/runtime/vector.gr
let size = untagSimpleNumber(vector.size)
let capacity = WasmArrayRef.length(vector.data)
if (size == capacity) {
let capacity = capacity << 2n

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this grows it by 4x. was there a reason to do so vs 2x?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Through testing, I found that there was negligible performance or space loss from this on smaller and medium test cases, but it greatly helped with the performance of massive cases.

Comment thread stdlib/runtime/vector.gr
* @returns The value that was removed from the vector
*/
@unsafe
provide let pop = (vector: Vector<a>) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correct me if wrong but in theory we could have a empty vector popped creating a negative tagSimpleNumber

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we absolutely could but this isn't a user library its a runtime library, and doing a bounds check here would incurr quite a cost given its in the hot path of printing.

The vector library is a small no checks runtime only library that must be used with extreme care.

Comment thread stdlib/runtime/vector.gr Outdated
Comment thread stdlib/runtime/miniBuffer.gr
Comment thread stdlib/runtime/doc.gr

@ospencer ospencer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this looks like it's going to be a big improvement! Direction looks good. Noting a couple of small things I found.

I mentioned this on the community call, but it looks like arrays lost their space in the beginning ([> 1, 2, 3]). That's how the formatter does it.

I think you've got a bug in how FitFlat and FitBreaking work versus the original Doc engine, but I haven't verified. Worth adding tests since toString doesn't use those.

Comment thread compiler/test/runtime/toString.test.gr Outdated
@spotandjake

Copy link
Copy Markdown
Member Author

Fixed all those concerns and added a test for the different engine behaviours.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants