|
| 1 | += WebAssembly and advanced regular expressions with Opal |
| 2 | +webdev778 |
| 3 | +v1.0, 2021-06-26 |
| 4 | +:doctype: book |
| 5 | +:docinfo: |
| 6 | + |
| 7 | +NOTE: This is also published as a |
| 8 | +https://opalrb.com/blog/2021/06/26/webassembly-and-advanced-regexp-with-opal/[guest post] |
| 9 | +at the Opal website. Thank you and a great hug to the Opal team! |
| 10 | + |
| 11 | +== Background |
| 12 | + |
| 13 | +At https://github.com/riboseinc[Ribose] we develop |
| 14 | +https://github.com/interscript/interscript[Interscript], an open source Ruby |
| 15 | +implementation of interoperable transliteration schemes from |
| 16 | +https://www.loc.gov/catdir/cpso/roman.html[ALA-LC], |
| 17 | +https://www.usgs.gov/core-science-systems/ngp/board-on-geographic-names[BGN], |
| 18 | +https://www.gov.uk/government/groups/the-permanent-committee-on-geographical-names[PCGN], |
| 19 | +https://www.icao.int[ICAO], https://www.iso.org[ISO], https://www.un.org[UN] (by |
| 20 | +UNGEGN) and many, many other script conversion system authorities. The goal of |
| 21 | +this project is to achieve interoperable transliteration schemes allowing |
| 22 | +quality comparisons. |
| 23 | + |
| 24 | +We needed to port the Interscript runtime to JavaScript using |
| 25 | +https://opalrb.com[Opal] (the Ruby to JavaScript compiler), so it can be also |
| 26 | +used in web browsers and https://nodejs.org[Node.js] environments. |
| 27 | + |
| 28 | +The problem is that Opal translates Ruby regular expressions (upon which we rely |
| 29 | +quite heavily) to JavaScript almost verbatim. This made our ported codebase |
| 30 | +incompatible on principle, so we searched for a better solution. |
| 31 | + |
| 32 | +Unfortunately, Regexp is basically something like a programming language that |
| 33 | +has more than a dozen of incompatible implementations -- even across the web |
| 34 | +browsers. For instance, we need lookbehind assertions, but even if there is a |
| 35 | +https://tc39.es/proposal-regexp-lookbehind/[new standard in ECMAScript] which |
| 36 | +adds lookbehind assertions, Safari doesn't implement that. |
| 37 | + |
| 38 | +Given all this context let's dive into how we ported the original Ruby Regexp |
| 39 | +engine to the browser! |
| 40 | + |
| 41 | +== Onigmo |
| 42 | + |
| 43 | +We started by trying to compile https://github.com/k-takata/Onigmo[Onigmo] with |
| 44 | +WebAssembly. |
| 45 | + |
| 46 | +Onigmo is a Regexp engine used by Ruby. It is a fork of |
| 47 | +https://github.com/kkos/oniguruma[Oniguruma], which |
| 48 | +is also in use by PHP and a few more programming languages. Fortunately, it was |
| 49 | +possible to compile it to a static WebAssembly module which can be interfaced |
| 50 | +with the JavaScript land. |
| 51 | + |
| 52 | +We tried compiling Onigmo using a simple handcrafted `libc` with no memory |
| 53 | +management so as to reduce the size, but this plan backfired, and rightfully so! |
| 54 | + |
| 55 | +Now we use https://github.com/WebAssembly/wasi-libc[wasi-libc]. WASI stands for |
| 56 | +WebAssembly System Interface, and is designed to provide "`a wide array of |
| 57 | +POSIX-compatible C APIs`". |
| 58 | + |
| 59 | +The library is made to be able to work with both `wasi-libc` and the handcrafted |
| 60 | +`libc`, but use of `wasi-libc` is highly encouraged. As we are concerned about the |
| 61 | +output size of the resulting WASM binaries, we chose not to use |
| 62 | +https://emscripten.org[Emscripten], just |
| 63 | +the upstream LLVM/Clang and its WASM target. |
| 64 | + |
| 65 | +== Opal-WebAssembly |
| 66 | + |
| 67 | +After getting Onigmo working, we noted, that the WebAssembly interface doesn't |
| 68 | +map 100% between C and JS. We can't pass strings verbatim and we need to do |
| 69 | +memory management (think: pointers). Is there a better solution for that than |
| 70 | +writing an Opal library to interface WebAssembly libraries? |
| 71 | + |
| 72 | +The solution we came up with is |
| 73 | +https://github.com/interscript/opal-webassembly[opal-webassembly]. |
| 74 | + |
| 75 | +This library is divided in two parts: |
| 76 | + |
| 77 | +* a simple WebAssembly interface |
| 78 | +* a Ruby-FFI compatible binding that works on everything memory-related and |
| 79 | + brings C functions to seamlessly work with the Ruby (Opal, that is) |
| 80 | + workflow. |
| 81 | + |
| 82 | +This library can be used in more advanced use cases beyond Interscript. Its |
| 83 | +interface is rather compatible with Ruby-FFI allowing C API bindings across all |
| 84 | +Ruby implementations. There are some minor incompatibilities though. |
| 85 | + |
| 86 | +Ruby-FFI assumes a shared memory model. WebAssembly has different memory spaces |
| 87 | +for a calling process and each library (think about something like a segmented |
| 88 | +memory). This makes some assumptions false. |
| 89 | + |
| 90 | +For instance, for the following code, we don't know which memory space to use: |
| 91 | + |
| 92 | +[source,ruby] |
| 93 | +---- |
| 94 | +FFI::MemoryPointer.new(:uint8, 1200) |
| 95 | +---- |
| 96 | + |
| 97 | +This requires us to use a special syntax, like: |
| 98 | + |
| 99 | +[source,ruby] |
| 100 | +---- |
| 101 | +LibraryName.context do |
| 102 | + FFI::MemoryPointer.new(:uint8, 1200) |
| 103 | +end |
| 104 | +---- |
| 105 | + |
| 106 | +This context call makes it clear that we want this memory to be allocated in the |
| 107 | +`LibraryName` space. |
| 108 | + |
| 109 | +Another thing is that a call like the following: |
| 110 | + |
| 111 | +[source,ruby] |
| 112 | +---- |
| 113 | +FFI::MemoryPointer.from_string("Test string") |
| 114 | +---- |
| 115 | + |
| 116 | +Would not allocate the memory, but share the memory between the calling process |
| 117 | +and the library. In |
| 118 | +https://github.com/interscript/opal-webassembly[opal-webassembly] we must |
| 119 | +allocate the memory, as sharing is not an option in the WASM model. |
| 120 | + |
| 121 | +Now, another issue comes into play. In regular Ruby a call similar to this |
| 122 | +should allocate the memory and clear it later, once the object is destroyed. In |
| 123 | +our case, we can't really access JavaScript's GC. This means we always need to |
| 124 | +free the memory ourselves. |
| 125 | + |
| 126 | +Due to some Opal inadequacies, we are unable interface floating-point fields in |
| 127 | +structs. This doesn't happen in Onigmo, but if needed in the future, a |
| 128 | +pack/unpack implementation for those will be needed. |
| 129 | + |
| 130 | +The Chromium browser doesn't allow us to load WebAssembly modules larger than |
| 131 | +4KB synchronously. This means that we had to implement some methods for awaiting |
| 132 | +the load. This also means, that in the browser we can't use the code in a |
| 133 | +following way: |
| 134 | + |
| 135 | +[source,html] |
| 136 | +---- |
| 137 | +<script src='file.js'></script> |
| 138 | +<script> |
| 139 | + Opal.Library.$new(); |
| 140 | +</script> |
| 141 | +---- |
| 142 | + |
| 143 | +This approach works in Node.js and possibly in other browsers, but Chromium |
| 144 | +requires us to use promises: |
| 145 | + |
| 146 | +[source,html] |
| 147 | +---- |
| 148 | +<script src='file.js'></script> |
| 149 | +<script> |
| 150 | + Opal.WebAssembly.$wait_for("library-wasm").then(function() { |
| 151 | + Opal.Library.$new(); |
| 152 | + }); |
| 153 | +</script> |
| 154 | +---- |
| 155 | + |
| 156 | +There are certain assumptions of how a library should be loaded on Opal side -- |
| 157 | +the FFI library creation depends on the WebAssembly module being already loaded, |
| 158 | +so we need to either move those definitions to a `wait_for` block or move require |
| 159 | +directives, like so: |
| 160 | + |
| 161 | +[source,ruby] |
| 162 | +---- |
| 163 | +WebAssembly.wait_for "onigmo/onigmo-wasm" do |
| 164 | + require 'interscript' |
| 165 | + require 'my_application_logic' |
| 166 | +end |
| 167 | +---- |
| 168 | + |
| 169 | + |
| 170 | +== Opal-Onigmo |
| 171 | + |
| 172 | +After having a nice library |
| 173 | +(https://github.com/interscript/opal-webassembly[opal-webassembly]) to bind with |
| 174 | +WebAssembly modules, writing an individual binding was very easy and the |
| 175 | +resulting code looks (in my opinion) very cool. |
| 176 | + |
| 177 | +Our initial plan assumed upstreaming the code later on, but on further |
| 178 | +consideration it might not be the correct choice for Opal. This library should |
| 179 | +stay as a separate gem for a couple of reasons. |
| 180 | + |
| 181 | +The resulting work is https://github.com/interscript/opal-onigmo[opal-onigmo], |
| 182 | +available on GitHub. |
| 183 | + |
| 184 | +First, due to memory issues, we aren't able to make it work as a drop-in |
| 185 | +replacement. We need to manually call an `#ffi_free` method. |
| 186 | + |
| 187 | +For example: |
| 188 | + |
| 189 | +[source,ruby] |
| 190 | +---- |
| 191 | +re = Onigmo::Regexp.new("ab+") |
| 192 | +# use the regular expression |
| 193 | +re.ffi_free # free it afterwards and not use it anymore |
| 194 | +---- |
| 195 | + |
| 196 | +At early stages our implementation of Opal-Onigmo we didn't consider the memory |
| 197 | +a problem. When hit with a real world scenario, we found out, that it's a severe |
| 198 | +issue and needs to be dealt with. As far as we know, the library doesn't leak |
| 199 | +any memory if the regular expression memory is managed correctly. |
| 200 | + |
| 201 | +The second is that after all, we don't really have a way of caching the compiled |
| 202 | +Regexps. Furthermore, Onigmo compiled with WASM may not be as performant as the |
| 203 | +highly optimized JS regexp engine. In this case it's much better to leave it as |
| 204 | +a drop-in replacement for those who need more correctness. |
| 205 | + |
| 206 | +Opal-Onigmo doesn't implement all the methods for Ruby Regexp, it was mostly |
| 207 | +meant for completion of the Interscript project, but can be extended beyond. It |
| 208 | +implements a few methods it needs to implement for String (this is just an |
| 209 | +option - you need to load onigmo/core_ext manually), but most of the existing |
| 210 | +ones work without a problem. We implemented a `Regexp.exec` (JavaScript) method, |
| 211 | +and the rest of Opal happened to mostly interface with it. At the current time |
| 212 | +we know that `String#split` won't "just" work, but |
| 213 | +`String#{index,rindex,partition,rpartition}` should. |
| 214 | + |
| 215 | +Opal-Onigmo depends on the strings being coded as UTF-16. There are two reasons |
| 216 | +to that: |
| 217 | + |
| 218 | +. Opal includes methods for getting the binary form of strings in various |
| 219 | + encodings, but only methods for UTF-16 are valid for characters beyond the |
| 220 | + Basic Multilingual Plane (Unicode 0x0000 to 0xffff) which are used in 2 maps. |
| 221 | + |
| 222 | +. JavaScript uses UTF-16 strings internally. |
| 223 | + |
| 224 | + |
| 225 | + |
| 226 | +== Interscript |
| 227 | + |
| 228 | +Finally by using `opal-onigmo`, the Opal-generated code passes _all_ the tests |
| 229 | +(not counting transliterating Thai scripts which ultimately depends on an |
| 230 | +external process, which relies on machine learning). |
| 231 | + |
| 232 | +To optimize it, we use `opal-onigmo` _only_ when the regexp is a more complex |
| 233 | +regexp, otherwise we fall back to an (ultimately faster) JavaScript regexp |
| 234 | +engine: |
| 235 | + |
| 236 | +[source,ruby] |
| 237 | +---- |
| 238 | +def mkregexp(regexpstring) |
| 239 | + @cache ||= {} |
| 240 | + if s = @cache[regexpstring] |
| 241 | + if s.class == Onigmo::Regexp |
| 242 | + # Opal-Onigmo stores a variable "lastIndex" mimicking the JS |
| 243 | + # global regexp. If we want to reuse it, we need to reset it. |
| 244 | + s.reset |
| 245 | + else |
| 246 | + s |
| 247 | + end |
| 248 | + else |
| 249 | + # JS regexp is more performant than Onigmo. Let's use the JS |
| 250 | + # regexp wherever possible, but use Onigmo where we must. |
| 251 | + # Let's allow those characters to happen for the regexp to be |
| 252 | + # considered compatible: ()|.*+?{} ** BUT NOT (? **. |
| 253 | + if /[\\$^\[\]]|\(\?/.match?(regexpstring) |
| 254 | + # Ruby caches its regexps internally. We can't GC. We could |
| 255 | + # think about freeing them, but we really can't, because they |
| 256 | + # may be in use. |
| 257 | + @cache[regexpstring] = Onigmo::Regexp.new(regexpstring) |
| 258 | + else |
| 259 | + @cache[regexpstring] = Regexp.new(regexpstring) |
| 260 | + end |
| 261 | + end |
| 262 | +end |
| 263 | +---- |
| 264 | + |
| 265 | +It also never frees the Regexps (see a previous note about `#ffi_free`), because |
| 266 | +we never know if a Regexp won't be in use later on (and the Regexps are actually |
| 267 | +cached in a Hash for performance reasons). The issue about dangling Regexps can |
| 268 | +be worked out in the future, but the JS API will need to change again. |
| 269 | + |
| 270 | +We would need to do something like: |
| 271 | + |
| 272 | +[source,ruby] |
| 273 | +---- |
| 274 | +Opal.Interscript.$with_a_map("map-name", function() { |
| 275 | + // do some work with a map |
| 276 | +}); |
| 277 | +---- |
| 278 | + |
| 279 | +This call would at the beginning allocate all the Regexps needed, and at the |
| 280 | +end, free them all. The good news is that we would be able to somehow integrate |
| 281 | +loading transliteration maps from the network (along with dependencies) with |
| 282 | +such a construct. |
| 283 | + |
| 284 | +== The future |
| 285 | + |
| 286 | +Post writing this article we noted that JavaScript actually does implement a |
| 287 | +construct that would work like a destructor, allowing us to free the allocated |
| 288 | +memory dynamically. Unfortunately, that is the latest ECMAScript addition, which |
| 289 | +means there are still environments that don't support it (Safari) and there is |
| 290 | +one that needs an explicit flag (Node 13+). |
| 291 | + |
| 292 | +https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/FinalizationRegistry |
| 293 | + |
| 294 | +We could use it to implement some parts of ObjectSpace of Ruby and then use it |
| 295 | +in `opal-webassembly` to free memory on demand. |
| 296 | + |
| 297 | + |
| 298 | +== Postscript |
| 299 | + |
| 300 | +This article was written long before it was published. Since then, Interscript |
| 301 | +has been rewritten in a different architecture and does not relies on Opal. |
| 302 | + |
| 303 | +While we no longer use Regexps directly, we have created a higher-level (Ruby) |
| 304 | +DSL to describe the transliteration process that we compile directly to a |
| 305 | +highly-optimized pure Ruby/JavaScript code (and it can be extended to other |
| 306 | +languages as well). |
| 307 | + |
| 308 | +Ribose still uses Opal in other projects, for example to build the |
| 309 | +https://github.com/plurimath/latexmath[`latexmath` gem], a library that compiles |
| 310 | +LaTeX math expressions into MathML, as a JavaScript library. We also contribute |
| 311 | +fixes back to the upstream Opal project. |
| 312 | + |
| 313 | +For the Opal project, this effort serves as an interesting experiment to |
| 314 | +establish further guidelines should we decide to increase Regexp compatibility |
| 315 | +in the future and can serve as a useful tool for anyone wanting to port his Ruby |
| 316 | +codebase with a heavy regexp use to JavaScript. It should also facilitate |
| 317 | +porting libraries that utilize Ruby-FFI. |
| 318 | + |
| 319 | +The libraries we created are available under a 2-clause BSD license in the |
| 320 | +following repositories: |
| 321 | + |
| 322 | +* https://github.com/interscript/Onigmo - Onigmo port to WebAssembly |
| 323 | +* https://github.com/interscript/opal-onigmo - the Onigmo interface to Opal |
| 324 | +* https://github.com/interscript/opal-webassembly - the FFI-like interface to Opal, using WebAssembly |
| 325 | +* https://github.com/interscript/interscript/tree/v1 - the obsolete v1 branch of Interscript that used Opal and Opal-Onigmo |
| 326 | + |
| 327 | +Enjoy Opaling! |
0 commit comments