TIL: Parsing DOCX files with Saxy - Handling Stream vs String Input

I’ve done further testing and noticed some performance and memory usage gains by flattening IOData more efficiently.

The benchmarking results show a large difference between the methods, which I’m skeptical about. However, it does seem to point in a promising direction. I ran into issues when trying to read straight from the file during the benchmarking, so I had to store the data in a variable before running the tests. The document I tested was a standard 5.9MB MS Word file.

Here’s the function I created:

defmodule StreamIodata do  
  def flatten_iodata_stream(iodata_stream, opts \\ []) do
    chunk_size = Keyword.get(opts, :chunk_size, 65_536)

    Stream.transform(
      iodata_stream,
      fn -> [] end,
      fn iodata, acc ->
        acc = [acc | iodata]
        acc_size = IO.iodata_length(acc)

        if acc_size >= chunk_size do
          # Flatten the accumulated iodata into a binary
          binary = IO.iodata_to_binary(acc)
          # Reset the accumulator
          { [binary], [] }
        else
          # Keep accumulating
          { [], acc }
        end
      end,
      fn
        # After function to flush any remaining data
        acc when acc == [] -> []
        acc -> [IO.iodata_to_binary(acc)]
      end
    )
  end
end

And here’s the benchmark:

defmodule Bench do
  def benchmark_docx_parsing(data) when is_list(data) do
    Benchee.run(
      %{
        "Stream with flatten_iodata_stream (8kb)" => fn ->
          data
          |> StreamIodata.flatten_iodata_stream(chuck_size: 8_192)
          |> Saxy.parse_stream(TestDocxHandler, [])
        end,
        "Stream with flatten_iodata_stream (64kb)" => fn ->
          data
          |> StreamIodata.flatten_iodata_stream(chuck_size: 65_536)
          |> Saxy.parse_stream(TestDocxHandler, [])
        end,
        "Stream with flatten_iodata_stream (524Kb)" => fn ->
          data
          |> StreamIodata.flatten_iodata_stream(chuck_size: 524_288)
          |> Saxy.parse_stream(TestDocxHandler, [])
        end,
        "Stream with iodata_to_binary map" => fn ->
          data
          |> Stream.map(&IO.iodata_to_binary/1)
          |> Saxy.parse_stream(TestDocxHandler, [])
        end
      },
      time: 10,
      memory_time: 2
    )
  end
end

# Storing the file in memory to avoid issues when reading from the file during benchmarking
zip_file = 
  path
  |> Unzip.LocalFile.open()

{:ok, unzip} = Unzip.new(zip_file)

data =  
  unzip
  |> Unzip.file_stream!("word/document.xml")
  |> Enum.into([])

Bench.benchmark_docx_parsing(data)

Here’s the output:

Error trying to determine erlang version enoent, falling back to overall OTP version
Operating System: macOS
CPU Information: Apple M1
Number of Available Cores: 8
Available memory: 8 GB
Elixir 1.17.2
Erlang 27
JIT enabled: true

Benchmark suite executing with the following configuration:
warmup: 2 s
time: 10 s
memory time: 2 s
reduction time: 0 ns
parallel: 1
inputs: none specified
Estimated total run time: 56 s

Benchmarking Stream with flatten_iodata_stream (524Kb) ...
Benchmarking Stream with flatten_iodata_stream (64kb) ...
Benchmarking Stream with flatten_iodata_stream (8kb) ...
Benchmarking Stream with iodata_to_binary map ...
Calculating statistics...
Formatting results...

Name                                                ips        average  deviation         median         99th %
Stream with flatten_iodata_stream (524Kb)      872.32 K        1.15 μs  ±4444.97%        0.88 μs           4 μs
Stream with flatten_iodata_stream (64kb)       871.69 K        1.15 μs  ±4267.88%        0.88 μs        3.04 μs
Stream with flatten_iodata_stream (8kb)        753.12 K        1.33 μs  ±6291.89%        0.88 μs        4.33 μs
Stream with iodata_to_binary map                 1.75 K      570.29 μs    ±68.51%      507.23 μs     1230.03 μs

Comparison: 
Stream with flatten_iodata_stream (524Kb)      872.32 K
Stream with flatten_iodata_stream (64kb)       871.69 K - 1.00x slower +0.00083 μs
Stream with flatten_iodata_stream (8kb)        753.12 K - 1.16x slower +0.181 μs
Stream with iodata_to_binary map                 1.75 K - 497.47x slower +569.14 μs

Memory usage statistics:

Name                                         Memory usage
Stream with flatten_iodata_stream (524Kb)         1.77 KB
Stream with flatten_iodata_stream (64kb)          1.77 KB - 1.00x memory usage +0 KB
Stream with flatten_iodata_stream (8kb)           1.77 KB - 1.00x memory usage +0 KB
Stream with iodata_to_binary map                896.40 KB - 505.46x memory usage +894.63 KB

**All measurements for memory usage were the same**
4 Likes