Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

jco componentize out of memory #568

Open
ianthetechie opened this issue Feb 14, 2025 · 2 comments
Open

jco componentize out of memory #568

ianthetechie opened this issue Feb 14, 2025 · 2 comments

Comments

@ianthetechie
Copy link

Hey all, thanks for the cool project! I've been following various talks in the space for the last year and am really excited to be trying out some stuff!

Background

I'm working in a Rust codebase that's replacing a bunch of JavaScript. To accelerate the process, I'm looking at componentizing some of the JS modules.

The first project I picked is a bit of a doozy since, while the business logic is pretty straightforward and doesn't use too much craziness, the core is wholly dependent on some files on disk which consist of rules that drive things. This turns out to be fairly large; several megabytes worth of data uncompressed.

Here's my WIT file; it's pretty basic. Just take some input and spit out some output (JSON, but I'll deal with better types later).

package local:parser;
world parser {
  export parse: func(input: string) -> string;
}

Initial approach

I could theoretically replace most portions of the library that currently rely on node fs and path APIs with WASI equivalents and hard-coded values respectively, but it seemed like lot less trouble to just bake these assets into the package. So, a few swings and a lot of jq later, I've transformed all of the file code into require('./data.js') essentially. It works!!

I've also figured out what seems to be a working rollup config and worked that into my tooling. There are now no external imports, and jco componentize gets past the first steps that otherwise failed when I had a dependency on fs or path.

The issue

When I run jco componentize, I get the following error:

Exception while evaluating top-level script
uncaught exception: out of memory
Additionally, some promises were rejected, but the rejection never handled:
Promise rejected but never handled: "out of memory"
Stack:

Error: the `componentize.wizer` function trapped

Caused by:
    0: error while executing at wasm backtrace:
           0: 0x7928c1 - <unknown>!<wasm function 12716>
           1: 0x793234 - <unknown>!<wasm function 12732>
           2: 0x204f5 - <unknown>!<wasm function 98>
           3: 0x1f4b0 - <unknown>!<wasm function 96>
           4: 0xa5bc - <unknown>!<wasm function 78>
           5: 0x22a072 - <unknown>!<wasm function 5033>
    1: Exited with i32 exit status 1
(jco componentize) Error: Failed to initialize the compiled Wasm binary with Wizer:
Wizering failed to complete
    at componentize (file:///Users/ianthetechie/pelias-parser-component/node_modules/@bytecodealliance/jco/node_modules/@bytecodealliance/componentize-js/src/componentize.js:305:13)
    at async componentize (file:///Users/ianthetechie/pelias-parser-component/node_modules/@bytecodealliance/jco/src/cmd/componentize.js:11:25)
    at async file:///Users/ianthetechie/pelias-parser-component/node_modules/@bytecodealliance/jco/src/jco.js:213:9

Process finished with exit code 1

The full command, in case it's useful, is jco componentize --wit wit --world-name parser -o dist/parser.wasm bundle/parser.bundled.js.

I also tried it with the --aot flag and, after like 10 seconds, I got a similar error message.

I have a hunch that this is something to do with the size of the code, but I figured I'd ask the experts before spinning my wheels too much more, as I may have just missed something dumb.

Thanks in advance!

@ianthetechie
Copy link
Author

ianthetechie commented Feb 15, 2025

Quick update: after a bit of hacking around, I was able to get it to build a component. I haven't had a chance to test if it WORKS yet in a host but that's progress!

The way I got it to build was by deferring the initialization. The few megabytes of structured data are still there, but I lazily do the initialization that processes them (ex: turning a dictionary with string keys into regex objects). So it seems the initial limit is something to do with pre initializing the WASM environment.

Is there a way to raise the limits? It would be perfectly acceptable for our use case to have a larger pre initialized memory :)

(The resulting wasm is pretty large btw around 45MB but that's fine for my use case.)

UPDATE 2: I was able to get it in a Rust host app, but ended up hitting a "wasm unreachable instruction executed" error 🤔 Unfortunately, per #537, I don't have a great way to debug this. Looking at the output of wasm-tools print, there are 5548 unreachable instructions 🤯

UPDATE 3: I eventually got it working under some circumstances, but with the following caveats.

My original approach to the port was pretty dumb and each "file" was just mapped into a JS object that held strings. Running this logic as part of the main init process caused an OOM. Reworking SOME of the processing logic (basically splitting the files into lines and then splitting each line on some delimiter and flattening into a single array) made the difference between being able to build the module or not.

However, the "lazy init" approach caused some issues; it would hit the unreachable instruction error, whereas I was able to eliminate this by doing the heavy loading into a "global" at the start of the JS entry point. This is probably a better idea anyways :) I just had to do a lot more work to avoid hitting either an OOM at jco componentize time or a confusing runtime error.

The other annoying part is that this baloons the final component size to 94MB 😅 For reference, the bundled JavaScript is around 10MB (most of this being data files).

In addition to what feels like some sort of memory limit during componentization, the other unsolved mystery is why AOT doesn't work. In the final working configuration, I get the following error with the --aot flag:

(jco componentize) ComponentError: Failed to decode Wasm
decoding item in module

Caused by:
    magic header not detected: bad magic number - expected=[
        0x0,
        0x61,
        0x73,
        0x6d,
    ] actual=[
        0xd0,
        0x4,
        0x0,
        0x0,
    ] (at offset 0x0)
    at componentNew (file:///Users/ianthetechie/pelias-parser-component/node_modules/@bytecodealliance/jco/obj/wasm-tools.js:3618:11)
    at componentNew (file:///Users/ianthetechie/pelias-parser-component/node_modules/@bytecodealliance/jco/src/api.js:37:10)
    at async componentize (file:///Users/ianthetechie/pelias-parser-component/node_modules/@bytecodealliance/jco/node_modules/@bytecodealliance/componentize-js/src/componentize.js:419:5)
    at async componentize (file:///Users/ianthetechie/pelias-parser-component/node_modules/@bytecodealliance/jco/src/cmd/componentize.js:11:25)
    at async file:///Users/ianthetechie/pelias-parser-component/node_modules/@bytecodealliance/jco/src/jco.js:213:9

This sounds like it might be an issue with weval, not jco, but recording the experience in case it's helpful.

I should be able to post this all up in a repo with an MRE sometime this week.

@ianthetechie
Copy link
Author

I apologize that this has turned into a bit of an "I'm not sure what's going on" issue; please feel free to split into sub-issues as you can identify them...

Here are some more confusing points which may or may not be bugs in jco directly...

Deterministic failure after multiple executions

After some number of executions in a loop using criterion from the Rust side, I end up hitting an unreachable instruction (it originates from my function, but the backtrace is unusable). If I fully instantiate a new component every time, the issue goes away, but I'm left with an unacceptable component instantiation delay :/

The weird (but I guess it should be comforting) part is that it's completely deterministic for a given wasm component build. Changing seemingly insignificant portions of the code that should not affect functionality can affect the number of iterations required before hitting an unreachable instruction.

I suspected there was a Store configuration issue preventing allocating a lot of memory or something, but instantiating a new component each time solves the issue, even when reusing the same Store.

I've also tried setting custom StoreLimits in case it was crashing due to hitting some limit, (setting instance, tables, and memories to 1 million, as well as setting trap_on_grow_failure), but that didn't produce any variation.

As such, it seems like it's some sort of bug.

AOT compilation

I was eventually able to get it compiled with --aot after adding a dynamic import vars plugin for rollup. Not really sure why this was required, as it does marginally work in the normal configuration.

Unfortunately, the AOT compiled version doesn't work. It hits an unreachable instruction. Leaving the plugin enabled doesn't change the behavior of the non-aot version.

Example to make things concrete

Repo that builds the component: https://github.com/stadiamaps/pelias-parser-component

`States` struct (basically from wasmtime examples)
struct States {
    table: ResourceTable,
    ctx: WasiCtx,
    limits: StoreLimits,
}

impl States {
    pub fn new() -> Self {
        let table = ResourceTable::new();
        let ctx = WasiCtxBuilder::new().build();
        let limits = StoreLimitsBuilder::new()
            .instances(1_000_000)
            .tables(1_000_000)
            .memories(1_000_000)
            .trap_on_grow_failure(true)
            .build();
        Self {
            table,
            ctx,
            limits
        }
    }
}

impl WasiView for States {
    fn table(&mut self) -> &mut ResourceTable {
        &mut self.table
    }

    fn ctx(&mut self) -> &mut WasiCtx {
        &mut self.ctx
    }
}
wasmtime component bindgen invocation
bindgen!({
    path: "parser.wit",
    world: "address-parser",
    async: false,
    additional_derives: [
        serde::Deserialize,
        serde::Serialize,
        Clone,
        Hash,
    ],
});
Wrapper to make invocation easier
pub struct Parser {
    store: Store<States>,
    // This is an attempt at speeding up failure recovery.
    // Creating a new instance every time adds 7-10ms, which is less than ideal.
    instance_pre: component::AddressParserPre<States>,
    instance: AddressParser,
}

impl Parser {
    pub fn init_with_engine(engine: &Engine) -> wasmtime::Result<Self> {
        let start = Instant::now();
        let component = Component::from_file(engine, "/path/to/pelias-parser-component/dist/parser.wasm")?;
        eprintln!("Component init after {:?}", start.elapsed());
        // Construct store for storing running states of the component
        let wasi_view = States::new();
        let mut store = Store::new(engine, wasi_view);
        store.limiter(|state| &mut state.limits);  // Custom limits config; probably not necessary
        let linker = Linker::new(engine);
        let pre = linker.instantiate_pre(&component)?;
        let instance_pre = component::AddressParserPre::new(pre)?;
        let instance = component::AddressParser::instantiate(&mut store, &component, &linker)?;
        eprintln!("Instance init after {:?}", start.elapsed());
        Ok(Self {
            store,
            instance_pre,
            instance,
        })
    }

     // The main entry point. Inline probably not necessary; doesn't seem to make a difference in benchmarks.
    #[inline]
    pub fn parse(&mut self, input: &str) -> anyhow::Result<ParsedComponents> {
        // let instance = component::AddressParser::instantiate(&mut self.store, &self.component, &self.linker)?;
        // let instance = self.instance_pre.instantiate(&mut self.store)?;
        match self.instance.call_parse(&mut self.store, input) {
            Ok(comps) => Ok(comps),
            Err(e) => {
                // Failure recovery path to recreate the component.
                // Removing this will let you see which iteration the bench fails on.
                let start = Instant::now();
                self.instance = self.instance_pre.instantiate(&mut self.store)?;
                eprintln!("Re-instantiated after {:?} due to {e:?}", start.elapsed());
                self.instance.call_parse(&mut self.store, input)
            }
        }
    }
}
Benchmark code
use std::time::Instant;
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion};
use wasmtime::{Config, Engine, OptLevel};
use pelias_parser::Parser;

pub fn criterion_benchmark(c: &mut Criterion) {
    let input = "30 w 26 st nyc 10010";
    let mut config = Config::new();
    config.cranelift_opt_level(OptLevel::Speed);
    let engine = Engine::new(&config).expect("Unable to create engine");
    let mut parser = Parser::init_with_engine(&engine).unwrap();
    let start = Instant::now();
    let mut iter = 0;
    let mut failures = 0;

    c.bench_with_input(BenchmarkId::new("parse", input), &input.to_string(), |b, i| b.iter(|| {
        iter += 1;
        let res = parser.parse(i);
        if res.is_err() {
            if failures == 0 {
                eprintln!("#{iter} @ {:?} {res:?}", start.elapsed());
            }

            failures += 1;
        }
    }));

    if failures > 0 {
        eprintln!("{failures} / {iter} iterations failed");
    }
}

criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant