Requiem for a stringref
wingolog.org
wingolog.org
Here is an aside, I promise it’ll be relevant. I once visited Gerry Sussman in his office, he was very busy preparing for a class and I was surprised to see that he was preparing his slides on oldschool overhead projector transparencies. “It’s because I hate computers” he said, and complained about how he could design a computer from top to bottom and all its operating system components but found any program that wasn’t emacs or a terminal frustrating and difficult and unintuitive to use (picking up and dropping his mouse to dramatic effect).
And he said another thing, with a sigh, which has stuck with me: “Strings aren’t strings anymore.”
If you lived through the Python 2 to Python 3 transition, and especially if you lived through the world of using Python 2 where most of the applications you worked with were (with an anglophone-centric bias) probably just using ascii to suddenly having unicode errors all the time as you built internationally-viable applications, you’ll also recognize the motivation to redesign strings as a very thoughtful and separate thing from “bytestrings”, as Python 3 did. Python 2 to Python 3 may have been a painful transition, but dealing with text in Python 3 is mountains better than beforehand.
The WebAssembly world has not, as a whole, learned this lesson yet. This will probably start to change soon as more and more higher level languages start to enter the world thanks to WASM GC landing, but for right now the thinking about strings for most of the world is very C-brained, very Python 2. Stringref recognizes that if WASM is going to be the universal VM it hopes to be, strings are one of the things that need to be designed very thoughtfully, both for the future we want and for the present we have to live in (ugh, all that UTF-16 surrogate pair pain!). Perhaps it is too early or too beautiful for this world. I hope it gets a good chance.
it is not
python 2 made a disastrously wrong choice about how to add unicode support
python 3 inserted that disastrously wrong choice everywhere (though at least you no longer get compile errors when you put a non-ascii character in utf-8 or latin-1 in a comment, a level of brain damage i've never seen from any other language)
rust and golang made reasonable choices about how to handle unicode; python, by contrast, is a bug-prone mess
i've lost python error tracebacks generated by an on-orbit satellite because they contained a non-ascii character and so the attempt to encode them as text generated an encoding error. python's unicode handling catastrophe has made it unusable for any context where reliability is especially important
If you do foo.decode(), you are introducing an operation that can throw. If you are programming in Python for a reliability-critical environment, you should detect this at commit/test time and handle it appropriately.
Rust is every bit as Unicode-strict, but it’s harder to fail to notice that you have a failure path.
Meanwhile, Python 2 will just happily malfunction and carry on. Sure, the code keeps executing, but this doesn’t mean that you will actually get your error message out.
the code in question was not doing foo.decode() or foo.encode(). it was writing a string to a file. python 3 inserts implicit unicode encoding and decoding operations in every access to environment variables, file names, command line arguments, and file contents, unless you pass a special binary flag when you open the file, as if you were on fucking ms-dos.
all those things are byte strings, and rust and python 2 give you access to them as byte strings. python 3 instead prefers to insert subtle bugs into your program
Then there's https://gregoryszorc.com/blog/2020/01/13/mercurial%27s-journ... where he comments that the design choices of python3 forced implementing a large portion of core themselves and that had rust been a bit more mature they probably would just have migrated to that instead.
I know another one, HLSL – Microsoft’s language widely used to write graphics and compute shaders for Direct3D GPUs.
Python3 caught a programming error for you and you're mad about it. The traceback you got was an encoded form (bytes) that you were blinding decoding in ascii when it was in fact UTF-8. You can tell it to truck through with surrogateescape but surely you can agree that it would be insane to make that the default.
And with that basic idea that strings are just a different view on a bytestream (e.g. every string is a valid bytestream, but not every bytestream is a valid string) most of the painful python2-to-python3 transition could have been avoided. I really don't know what they've been thinking when the 'obviously right' solution ("UTF-8 everywhere") was right there in plain sight since around the mid-90's.
Can you elaborate?
Much of the pain of the transition was figuring out which strings were bytes and which were Unicode data. The actual spelling of the type names never seemed like a big deal to me.
(I do think Python 3 messed some things up. My current favorite peeve is the fact that iterating bytes yields ints. That causes a lot of type confusions to result in digit gobbledygook instead of a useful exception or static checker error.)
And for a lot of code (that which just passes data around), this shouldn't matter.
It's basically "Schroedinger's strings", you don't need to know if some data is valid string data until you actually need it as a string, and often this isn't needed at all (IMHO all encodings/decodings should be explicit, not just between bytestreams and strings, but also between different string encodings - and those should arguably go into different string types which cannot be assigned directly to each other - e.g. the standard string type should always only be UTF-8). Also, file operations should always work on bytestreams (same in the IO functions of the C stdlib btw).
Then you can pass around an untyped value, which is the default in all versions of Python. With type annotations, one can spell this typing.Any.
When you finally do need your value to be a string, you need to decide whether it’s a runtime error when it needs to be a string or whether it’s a runtime error way up the call stack. Especially if databases are involved (or network calls, etc), this decision matters.
> e.g. the standard string type should always only be UTF-8
It almost kind of sounds like you’re arguing in favor of Python 3’s design, where str is indistinguishable from UTF-8 except insofar as you need to actually ask for bytes (e.g. call encode()) to get the UTF-8 bytes.
> Also, file operations should always work on bytestreams
So how do you read a line from a text file?
> (same in the IO functions of the C stdlib btw).
Are we talking about the same C? The language where calling gets() at all is a severe security bug, where fgets returns int, and where fgetwc exists?
In that case you need to know upfront how the text file is encoded anyway, since text files don't carry that information around.
If it is a byte-stream encoding from the "ASCII heritage" like UTF-8, 7-bit ASCII, or codepaged 8-bit "ASCII" - whatever that is actually called...): load bytes until you encounter a 0x0A or 0x0D (and skip those when continuing), what has been loaded until then is a line in the text file's encoding. If the original encoding was codepaged 8-bit ASCII you probably want to convert that to UTF-8 next, for that you also need to know the proper codepage though (not needed for 7-bit ASCII since that already is valid UTF-8 - in UTF-8, every byte with the topmost bit cleared is guaranteed to be a standalone 7-bit ASCII character and every byte with the topmost bit set is part of a multi-byte sequence for codepoints above 127, that's why one can simply iterate byte by byte over an UTF-8 encoded byte stream when looking for 7-bit ASCII characters (such as newline and carriage-return).
The gist is that the file IO functions themselves should never be aware of text encodings, they should only work on bytes. The "text awareness" should happen in higher level code above the IO layer.
> Are we talking about the same C?
What I meant here - but expressed poorly - was that C also got that wrong (or rather the C stdlib, C itself isn't involved). There should be no "text mode IO" in the C stdlib IO functions either, only raw byte IO. And functions like gets(), fgets() etc... shouldn't be in the C stdlib in the first place.
https://docs.python.org/3/library/io.html#io.TextIOWrapper
open is just a factory function, conceptually inherited (I think) from C.
The string stuff is, IMO, something the group has not come to realize the "right direction" on, but so much has been done right! Hopefully strings can get there too. :)
Is it? Doesn't pretty much every language have a unicode string type (be that UTF16 in older languages or UTF8 in newer one) that is the default goto type for dealing with text these days? C and C++ being the notable exceptions I suppose.
Other then that high-level UNICODE stuff (like finding grapheme cluster boundaries) UTF-8 itself really works fine in C/C++ anywhere than Windows in the sense that I can write a foreign-language "Hello World!" and it "just works" (e.g. if the whole source file is UTF-8 encoded anyway, than C string literals are also automatically valid UTF-8 strings).
UNICODE on Windows is still a bigger mess than it should be because of its UCS-2 / UTF-16 heritage.
"π".localeCompare("Π", undefined, { sensitivity: "accent" })
My machine says these are equal, but I've seen cases where network stacks consider domains as different if they feature the same Greek letter in a different case even though domain names are supposed to be case-insensitive.
(The ztd proposal is IMO a big step in the right direction.)
Yes, and that preemption occurs at the opcode level. Which is completely defeated if your string matching opcode can hang for an inordinate amount of time because the user supplies a very large string and/or crafts string inputs that force O(n^2) or worse behaviour.
A sensible way forward would be to deprecate APIs for direct UTF-16 code unit access, but implement them for backward compatibility on top of an internal UTF-8 representation. On both sides.
We've lived with JS bloat for two decades, you think a few string copies/conversions are gonna kill us? Any non-toy uses of WebAssembly are gonna be new developments. Old shit that nobody is gonna run on WebAssembly other than to go "yeah, huh, it runs on WebAssembly" and then never use it again, doesn't need to run great.
for (int i = 0; i < str.length(); i++)
doSomethingWith(str.charAt(i));
UTF-8 just a text encoding. If you're making something new, yeah, it's the obvious choice, but it's not better enough to justify breaking all sorts of shit just to switch over.You never work on characters, you work on grapheme clusters or whatnot but never characters.
It was always O(n).
Of course assuming you aren't using UTF-32, which has its own set of problems (BE or LE), and sees little usage outside of China.
Saying it works fine if you ignore errors and avoid edge cases is just a clever rephrashing of it worked on my machine.
Plus Emojis are Unicode U+1F600 and above, so even in Western language you are bound to find such "exceptions" .
That’s how you write hashing algorithms, checksums, and certain trivial parsers.[0]
But most importantly, right or wrong, this code is out there, running today, god knows where, and you do not slow it down from O(n) to O(n^2).
That's changed in the newer versions, because String has a `byte[]` not a `char[]`, but it was just fine. A hash algorithm can take in bytes, characters, ints, it doesn't matter.
In Java, you don't get access to the bytes that make up a string, to preserve the string's immutability. So for many operations where you might operate on bytes in a lower level language, you end up using characters (unless you're the standard library, and you can finagle access to the bytes), or alternately doing a byte copy of the entire string.
I admit, checksums using characters are a bit weird sounding, but they should also be perfectly well-defined.
But like... if you're writing a website, just use JavaScript.
In the case of non-managed strings, this overhead hasn't been big enough to stop people from writing fairly fast (by Web standards) frontend frameworks in Rust.
Major uses of WebAssembly include things like Photoshop, Unity, and many other large existing codebases.
Well, what are we waiting for, then.
In fact, the JVM already has moved to a mix of ISO-8859-1/Latin-1 and UTF-16 (https://openjdk.org/jeps/254), and I expect many performant JavaScript implementations also do something in that direction.
For Java it’s a good saving because it reduces the overall heap size, and if JS has a similar distribution of objects then it should work well there as well. It may already do so, the internal storage format of things like strings and arrays is deliberately opaque.
If so, using UTF-8 and only converting to UTF-16 the moment such seeking happens may be beneficial.
Problem, however, is that Java and JavaScript have C-style for loops that give false positives, where the code indexes into the string in order to iterate over it.
The issue is not new. JavaScript runtimes frequently use multiple optimized string types for various situations:
https://github.com/danbev/learning-v8/blob/master/notes/stri...
They make it simpler, but it also is possible to detect that loops are iteration-style access in disguise.
That takes time, so its more likely to happen in ahead of time compiled languages.
C compilers can vectorize some of such loops, so they have logic for doing that.
When you write extension modules for languages like Python, Ruby, PHP, or even JavaScript (via V8 or JavaScriptCore), you are always importing APIs like strings (eg. #include <ruby.h>). It seems natural to me that WebAssembly would be the same way.
Now ideally a WebAssembly module wouldn't be specific to one particular embedding environment. You would want to standardize this string API, so that a single API can be used efficiently with multiple host languages. So you'd want something more like #include <wasm/wtf16string.h>. But if it's an import, it also leaves open the possibility that such an API can evolve over time, or that a competing API could supplant it as the landscape of language engines changes. By putting it directly into the instruction set, you'd be baking in assumptions about how JS engines in 2023 function, even though those assumptions can (and hopefully will) change over time.
It's true that wasm/wtf16string.h probably won't be available in environments that don't have JS-style strings. But that seems for the best. I'd rather have a leaner WebAssembly than make every WebAssembly engine provide JS-style strings. In the case that the host environment doesn't already have a JS-style string that you are trying to interoperate with, why not just have Java implement its string type directly in WebAssembly? Or once shared libraries are available, wasm/wtf16string.h could be provided via some third-party library, shared across multiple modules, leaving open the possibility of passing strings around between modules.
I guess, to the article's point, why shouldn't memcpy() be an import also? What is gained by making it part of the instruction set? I assume that a WebAssembly implementation could recognize special imports, and optimize them the same as if it were a built-in instruction.
As mentioned in the article, the purpose of WebAssembly is not to provide the lowest level instruction support possible but to be a good compilation target. Not having a string type makes targeting WASM GC more complicated and the runtime less efficient.
> By putting it directly into the instruction set, you'd be baking in assumptions about how JS engines in 2023 function, even though those assumptions can (and hopefully will) change over time.
The stringref proposal does a good job of balancing the world we exist in with the "right thing", IMO. I implemented a subset of it for an interpreter hosted in Guile Scheme, using Guile's string type which is very unlike Java/JavaScript (not UTF-16), and it works well. I'd like to see the proposal get more support.
Take the simple example of memory.copy. How is it worse to compile memcpy() to import+memcpy() rather than memory.copy?
Why can't a string type be efficiently GC'd via an imported type?
The reason why you probably need a custom string type is so you can actually embed string literals without relying on interop with the environment. If a WASM module tries to simulate this by having an initialization function that constructs all your string constants in linear memory or something, I could see that getting pretty expensive and/or difficult to optimize.
I've never heard of a GC with that kind of API. Usually any native code that holds a GC reference would either mark that reference as a root explicitly (eg. https://github.com/WebAssembly/design/issues/1459) or ensure that it can be traced from a parent object. Either way, this should prevent collection of the object for as long as the reference is reachable from a root. I agree that explicitly checking whether a GC'd object has been freed would not make any sense.
> The reason why you probably need a custom string type is so you can actually embed string literals without relying on interop with the environment.
WASM already has ways of embedding flat string data. This can be materialized into GC/heap objects at module startup. This must happen in some form anyway, as all GC-able objects must be registered with the GC upon creation, for them to be discoverable as candidates for collection.
Overall I still don't understand the issue. There is so much prior art for these patterns in native extensions for Python, PHP, Ruby, etc.
(1) integration with a single GC (the one in the browser), and
(2) zero-copy inter-op with JavaScript strings.
If you take them as axioms, those pretty much force you to put strings as a native type in WASM. They can't really be done as libraries.
Not sure if it helps, but I wrote about my experience writing a GC here, and the "reality sandwich" metaphor:
https://www.oilshell.org/blog/2023/01/garbage-collector.html
https://lobste.rs/s/v5ferx/scheme_browser_hoot_tale#c_n87bzw
https://lobste.rs/s/v5ferx/scheme_browser_hoot_tale#c_mw6tfx
The sandwich has:
(1) the mutator's view on one side
(2) the single bit representation of data types in the middle
(3) the GC's view on the other side
Basically a bunch of people thought that you could just have one half of the sandwich -- you could just have the GC, and use whatever data types you want.
Implementing a GC will disabuse you of that notion (ESPECIALLY a GC that runs remote, untrusted code, but it's true for any GC.)
The core data types and the GC are tightly coupled, and the GC already lives in the browser.
So WASM necessarily gives you the WHOLE sandwich -- both data types and the GC. Fundamentally, it can't do anything else.
But that means that the data types are a huge design compromise -- there are winners and losers. Now you have a MAPPING problem from every language to WASM types.
---
As a concrete example, I mentioned that Go has slices, which are reference types, and they have pointers to anywhere in a string.
This means the GC has to be able to find the head of a string from an interior pointer, which is hard in general.
So, without being very close to WASM GC, I suspect Go will be a loser in this respect (it will perform less well), simply because JS GC's don't have to deal with interior pointers.
Every design decision involves winners and losers -- "universal VM" is a bit of a fallacy.
---
Strings are another area where there are winners and losers -- UTF-16 means JS/Java perform better, but UTF-8 means other languages perform better.
Although this stringref proposal is interesting because it tries not to be biased -- it tries to develop an API that can be implemented with both UTF-8 or UTF-16 representations. Still, it's very hard problem and involves deep compromises.
It's definitely the case that you can't implement a string type in WASM and have it interoperate with JavaScript strings.
But there is no reason that JavaScript strings can't be a "special" kind of import that provides functionality implemented by the engine. In other words, #include <wasm/wtf16string.h> could provide an API for the built-in JavaScript string, much like #include <ruby.h> provides access to the native Ruby string in Ruby extensions.
I believe this is how the counter-proposal works: https://github.com/WebAssembly/js-string-builtins/blob/main/...
For engines that do not have a built-in JavaScript-like string type, the wasm/wtf16string.h library could be implemented in WASM as a polyfill. In that case, there is no native string to interoperate with, so no disadvantage to implementing directly in WASM (except perhaps a bit of speed penalty).
Some languages targeting WebAssembly may have compatible primitives and would benefit from being able to use the equivalent JavaScript primitive for their implementation
I believe Java fits that bill (it's UTF-16, at least). So if you were writing a JVM to work on WASM, then you could possibly just import the JS string and use it, and get zero-copy inter-op.
---
But what the Wingo article is saying is they want something even more ambitious: have a working Scheme or Python or Lua implementation with zero-copy JS inter-op.
If they can paper over the bytes vs. UTF-16 vs. code points API issue, making a single string type that's not biased toward one or the other, I think it's a step in the right direction. (The aside about decoupling JS itself from UTF-16 string APIs is pretty intriguing too!)
But it's still not obvious to me that even this gets you all the way there. For example, Python interns some strings, and Lua apparently interns ALL strings, and they both have hash codes in the string object, and expose it to users. (Even changes to the hash function may break some programs)
Unique object IDs in many languages are another issue -- the JVM apparently uses space for them in the header of every object.
So you still may have more compromises about how to implement the WASM string, that produces winners and losers. The only way to know how the compromises pan out is to try, and I'm glad people are doing that.
---
So anyway, I'm not very close to WASM, but I do see a pretty clear difference between the proposals. I think it depends on what the goals are -- is "running Python or Scheme with zero-copy JS string interop" a goal?
I guess people want to manipulate the DOM directly from Scheme or Python, which seems reasonable. If you have to copy all the strings, that will be a huge drag. DOM operations are already hugely expensive and have tons of objects. It seems like the proposal you linked doesn't address that issue.
Happy to hear this.
There is a GC proposal: https://webassembly.github.io/gc/core/
V8 implemented it some months ago (ChromeNode and I guess Deno, with a experimental switch to enable support). WasmEdge isn't there yet: https://github.com/WasmEdge/WasmEdge/issues/1122#issuecommen... WasmTime finished the RFC for the implementation details in June: https://github.com/bytecodealliance/wasmtime/issues/5032
Except that UTF-16 makes a lot of sense on Windows, which won’t change anytime soon.
Since you always have to deal with noncharacters, initial vs. non-initial BOMs, isolated combining characters, etc., and you have to validate your inputs anyway (meaning you almost always need a failure path for unvalidated strings anyway), I’m not sure if (unpaired) surrogates constitute that much more of a complication.
http://www.oilshell.org/blog/2023/06/surrogate-pair.html#fut...
I am used to C and C++ so I guess I never thought of wasm as a runtime like C#+.NET so any help is appreciated.
1. One of the biggest WASM runtimes are browsers, and so it's likely that one would want to exchange strings between WASM and JS/DOM _efficiently_
2. The article states that most modern languages, if compiled to WASM, would likely need a lot of functionality in order to deal with unicode correctly (was how I interpreted the reference to libICU), which again would greatly increase the size of WASM blobs for those languages. Having stuff like that builtin would mitigate that.
>= can be 2 grapheme clusters is some fonts and 1 grapheme cluster in other fonts which show it as ≥
Display of Grapheme Clusters. Grapheme clusters are not the same as ligatures. For example, the grapheme cluster “ch” in Slovak is not normally a ligature and, conversely, the ligature “fi” is not a grapheme cluster. Default grapheme clusters do not necessarily reflect text display. For example, the sequence <f, i> may be displayed as a single glyph on the screen, but would still be two grapheme clusters.It's a fake.
I do wish they had used some symbol that can actually be pasted into the "Find" dialog, rather than being normalized into the number 1, which matches at least 75 (!) times.
What JS in general needs is a 'StringView' type, which is most useful for asm.js/wasm scenarios, but is useful for other scenarios too. You could define it for UTF16 only, or define it to work with both UTF8 and UTF16. Why?
Well, a very common use case for asm.js/wasm scenarios is that you have a big wasm application that needs to chat with things outside of the wasm sandbox. The vast majority of APIs available to a wasm application are browser APIs, which communicate using objects (we have a solution for those, externref), strings, doubles (native wasm support), i32s (semi-native support - v8 only supports 31-bit integers, but it's usually good enough) and booleans (just use i31s).
Right now if you want to send a string from wasm to a browser API, you need the help of some JS glue to construct a brand new JS string on demand from bytes living in the wasm heap. This glue is Not Fast, and it becomes increasingly Not Fast the larger your strings are. If you have a great many strings - for example, text that you want to cram into textareas or spans inside of a table in the DOM - you will waste a lot of time doing this. And then if you want to transfer strings back into wasm, you have to do the reverse, painstakingly copying the string back into the wasm heap one character at a time.
If you're lucky, the browser has native APIs that accelerate this process by decoding/encoding UTF16, but those APIs have limitations - for example, TextDecoder does not support SharedArrayBuffer, so enabling multithreading in your wasm application will instantly make it slower to send strings across the js/wasm boundary. Cool.
The widely used wasm platform I work on (.NET / Blazor) does a lot of sending strings across the js/wasm boundary, so we've had to jump through some hoops to optimize this as much as possible. For example, we maintain an 'interned string' table on both sides of the boundary so that commonly reused strings like method names or enum value names don't have to get encoded/decoded each time they cross. This significantly increases memory usage and means we have to jump through hoops to avoid using too much memory, but the advantage in terms of performance is measurable, upwards of 10% per boundary crossing even for small strings. A typical application has lots of grids, tables and lists filled with string data, so it spends a sizable amount of time doing all this string encoding/decoding. Incidentally this interning table is harder to implement efficiently because nobody is willing to expose a getHashCode equivalent for JS types or even specifically for JS strings, but I can understand that decision at least...
If you had a StringView type, you could basically go "here's an ArrayView, I promise it contains WTF16 (or UTF16) data, and I promise I won't mutate it. You can make a defensive copy if you want. Please turn it into something string-like so I can use it with DOM properties like textContent or APIs like WebSocket, thanks." JS runtimes already have support under the hood for diverse string storage formats, whether it's ropes, latin1 buffers, or utf16 buffers, so this would just be a new one of those.
Maybe we'll get one before 2030. Not holding out hope though, this was a known need at the very beginning of the WebAssembly spec process and I doubt anyone has forgotten about it, it just doesn't seem to be considered that important compared to features like "having multiple distinct address spaces for some reason" and "64-bit address spaces that probably won't be available in web browsers for 20 years because Chrome on Android is still 32-bit"