Skip to content

Almost twice as slow as serde_json reading large number arrays #158

Description

@rix0rrr

Struson version

0.7.2

Description

I want to read very large arrays of numbers into memory describing a graph (I'm trying to read the v8 heap snapshot format). Heap snapshots look like this:

{
    "snapshot": {
        "meta": {
            ...
        }
        ...
    },
    // Large arrays with millions of numbers
    "nodes": [9,1,1,0,10,0,0, 2,1,79,12,1,0,0, /* ... */],
    "edges": [9,1,1,0,10,0,0, 2,1,79,12,1,0,0, /* ... */],
    "locations": [9,1,1,0,10,0,0, 2,1,79,12,1,0,0, /* ... */],
    ...
}

Since snapshots are on the order of ~GBs, I thought a streaming parser would be more appropriate than serde, so I found and tried struson. If nothing else, since the expected sizes of the largest arrays are in the JSON file itself, using struson I could at least preallocate the Vecs to the correct size, so I could avoid the repeated resize+copy that a naive growing Vec allocation in serde_json would need to do.

But struson turns out not to be usable for this use case, because its number array parsing is almost twice as slow as serde_json, even accounting for the vec resizing that I'm able to avoid.

Expected behavior

Parsing a large array of numbers in struson to be competitive with serde_json (if not faster because of preallocation).

Actual behavior

The struson performance is a lot worse.

Reproduction steps

Here is a repro (it doesn't do the Vec preallocation mentioned above, but I'm getting ~the same numbers on this repo as on the real heap snapshot loading)

Use the following command to produce a file with a good amount of numbers in it:

python3 -c 'import json, random; json.dump(dict(numbers=[random.randint(0, 100000) for _ in range(2_000_000)]), open("numbers.json", "w"))'

Then run the following program:

use std::{error::Error, fs::File, io::BufReader, path::Path, time::Instant};

use serde::Deserialize;
use struson::{
    json_path,
    reader::{JsonReader, JsonStreamReader},
};

fn main() -> Result<(), Box<dyn Error>> {
    {
        let _t = start_timer("serde");
        println!("{}", read_using_serde(Path::new("numbers.json"))?);
    }

    {
        let _t = start_timer("struson");
        println!("{}", read_using_struson(Path::new("numbers.json"))?);
    }

    Ok(())
}

//////////////////////////////////////////////////////////////////////
// SERDE
#[derive(Debug, Deserialize)]
pub struct NumbersFile {
    pub numbers: Vec<u32>,
}

fn read_using_serde(path: &Path) -> Result<usize, Box<dyn Error>> {
    let file = File::open(path)?;
    let reader = BufReader::new(file);

    let snapshot: NumbersFile = serde_json::from_reader(reader)?;

    Ok(snapshot.numbers.len())
}

//////////////////////////////////////////////////////////////////////
// STRUSON
fn read_using_struson(path: &Path) -> Result<usize, Box<dyn Error>> {
    let file = File::open(path)?;
    let reader = BufReader::new(file);

    let mut r = JsonStreamReader::new(reader);

    // Accumulate into a Vec just like Serde would do
    let mut numbers = Vec::<u32>::new();

    r.seek_to(&json_path!["numbers"])?;
    r.begin_array()?;
    while r.has_next()? {
        numbers.push(r.next_number()??);
    }
    r.end_array()?;

    Ok(numbers.len())
}

//////////////////////////////////////////////////////////////////////
// TIMER
struct Timer {
    name: String,
    start: Instant,
}

fn start_timer(name: &str) -> Timer {
    Timer {
        name: name.to_string(),
        start: Instant::now(),
    }
}

impl Drop for Timer {
    fn drop(&mut self) {
        let duration = Instant::now() - self.start;
        println!("{}: {:?}", self.name, duration);
    }
}

Results:

$ cargo run
2000000
serde: 479.202834ms
2000000
struson: 823.245584ms

Release mode doesn't make a lot of difference:

$ cargo run --release
2000000
serde: 60.293ms
2000000
struson: 119.979833ms

Here's a flame graph I took of this:

Image

A lot of time seems to be spent in the has_next(). Even if I take that out, and turn it into an optimistic read-and-recover:

    r.seek_to(&json_path!["numbers"])?;
    r.begin_array()?;
    loop {
        let next = r.next_number::<u32>();
        if matches!(
            next,
            Err(ReaderError::UnexpectedStructure {
                kind: struson::reader::UnexpectedStructureKind::FewerElementsThanExpected,
                ..
            })
        ) {
            break;
        }

        numbers.push(next??);
    }
    r.end_array()?;

It doesn't seem to make that much difference:

$ cargo run --release
2000000
serde: 56.756792ms
2000000
struson: 111.093458ms
Image

(And what I highlighted only accounts for read_number_bytes, there is next_number_as_str and from_ascii_radix in the trace as well)

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestperformanceProblem with performance or suggestion for performance improvement

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions