Skip to content

Add fastwarc-grpc, a streaming gRPC server for FastWARC - #58

Draft
krickert wants to merge 12 commits into
chatnoir-eu:developfrom
krickert:fastwarc-grpc
Draft

Add fastwarc-grpc, a streaming gRPC server for FastWARC#58
krickert wants to merge 12 commits into
chatnoir-eu:developfrom
krickert:fastwarc-grpc

Conversation

@krickert

@krickert krickert commented Aug 12, 2026

Copy link
Copy Markdown

Follow-up to #57 adds fastwarc-grpc as a separate workspace crate, with documentation, tests, benchmarks, and CI coverage.

What is included

  • A separate fastwarc-grpc crate with a path dependency on the sibling fastwarc-rs crate. It inherits workspace package metadata and remains outside the default members because stub generation requires protoc.
  • Bidirectional ParseWarc and unary ParseArchive RPCs with shared parsing, filtering, digest verification, payload decoding, and error handling.
  • Local archive paths disabled by default. They require FASTWARC_GRPC_ALLOW_LOCAL_FILES=1 because they allow clients to read files from the server.
  • Service internals split into RPC orchestration, record parsing, response batching, and channel reader modules.
  • Crate documentation with server setup, streaming and unary examples, configuration, filters, errors, embedding, and non-Rust client guidance.
  • A loopback benchmark under benchmarks/warc/fastwarc-grpc. Its README records the reviewer reproduction and distinguishes single-stream results from aggregate multi-stream throughput.
  • A four-platform Rust build and test matrix for FastWARC and fastwarc-grpc. Each runner installs the matching precompiled protoc archive.

Local validation

The current PR head passes:

  • cargo test -p fastwarc-grpc: 48 tests, including 7 library tests, 38 integration tests, and 3 doctests
  • cargo fmt -p fastwarc-grpc --check
  • cargo clippy -p fastwarc-grpc --all-targets --no-deps -- -D warnings -D clippy::pedantic
  • RUSTDOCFLAGS="-D warnings" cargo doc -p fastwarc-grpc --no-deps
  • buf lint and buf format --diff --exit-code for the protobuf definitions

The tests reuse existing repository fixtures, including fastwarc-rs/tests/fixtures/warcfile.warc.zst.

@phoerious

phoerious commented Aug 13, 2026

Copy link
Copy Markdown
Member

The parser seems to be extremely slow. When I run the benchmark on an uncompressed WARC, I get around 300MiB/s, which is slower than most of the compressed options.

Summary: 17.3s, 6595 records/s, 305.8 MiB/s, 47.5 KiB/rec (114274 total, 5298.4 MiB)

Gzip and Zstd are around 480MiB/s, LZ4 is 510MiB/s.

For comparison, here's plain FastWARC:

Summary: 1.0s, 113948 records/s, 5283.3 MiB/s, 47.5 KiB/rec (114274 total, 5298.4 MiB)

My guess is that the streaming mechanism is quite inefficient or that the serving thread doesn't buffer enough so that the consumer is being starved.

@phoerious

phoerious commented Aug 13, 2026

Copy link
Copy Markdown
Member
flamegraph

Wild flamegraph. Lot's of kernel TCP IO calls here. FastWARC itself completely disappears.

For comparison: This is FastWARC alone.
image

@krickert

Copy link
Copy Markdown
Author

I'll look into this - there's a lot of things to do to speed this up - I started as minimalist here at first. Standalone pure rust will always be fastest but that flamegraph isn't normal. What tests are you using? I'll use the same ones and get it to speed up.

@phoerious

phoerious commented Aug 13, 2026

Copy link
Copy Markdown
Member

That is just the profiling binary run with cargo run. I'm using a test WARC file from the CommonCrawl, 5.3GB uncompressed size, 1.2GB compressed (Gzip).

@krickert

Copy link
Copy Markdown
Author

Give you give me the link to the specific file? I just wanna make sure I'm doing apples to apples. gRPC has a lot of tuning - window size is usually the first one that unblocks (the default is 64K and it should be like 50MB for this kind of crawl)

@phoerious

phoerious commented Aug 13, 2026

Copy link
Copy Markdown
Member

I don't think the exact file matters. I pulled it from our internal storage, so I don't have the upstream link at hand. The file name is CC-MAIN-20231005012006-20231005042006-00899.warc.gz. Any larger real-world WARC file should do.

@krickert

Copy link
Copy Markdown
Author

The file won't matter, but if I use the same one it's one less variable I'll question. At first glance it is using the 64kb window - for reference on my own fast machine when it's at 64KB, I can't get more than 3MB/s.. when I set it to 300MB, I can get 1.5GB per second over the wire... so I'm sure I can tune this just a little and can show you better results.

@phoerious

phoerious commented Aug 13, 2026

Copy link
Copy Markdown
Member

I temporarily put the file here: XXX
Uncompress locally for reproducing the uncompressed stream contention.

@krickert

Copy link
Copy Markdown
Author

Got it .. so for gzip we are getting about 1384MB/s vs. 1430MB/s. ...

for uncompressed initially I"m seeing grpc do 4367 MB/s... but that's against in-process uncompressed doing 14179 MB/s on the same machine uncompressed.

So that means about a 3x slower service if done as an over-the-wire service. I'm working to make it faster, there's plenty of room for speeding this up - this was just the window tuning so far.

I'll get back to ya in a few hours on this.

But 100% keeping as draft. I need a couple rounds of tuning.

If you don't mind, I'll tune it 1x today and ask you to re-test. You should see a huge improvement, but my gut is saying we'll want 1 more round of testing after that.

@phoerious

phoerious commented Aug 13, 2026

Copy link
Copy Markdown
Member

Don't run this on a converged SoC machine. It will inflate the numbers. When I run the same program on an M1 MacBook, I get uncompressed 890.4 MiB/s. That's still less than half of the 2698.7 MiB/s for plain FastWARC, though not as bad as the 300MiB/s I get on the other machine with a regular SSD and AMD64 CPU.

@phoerious

phoerious commented Aug 13, 2026

Copy link
Copy Markdown
Member

FWIW This is the flamegraph on the MacBook. It looks quite different (I mean the shape obviously, different platform, different names), though the actual FastWARC routines are still only about 1/8 of the total time and have a rather flat call stack compared to the async/grpc runtime. And that's just the fastwarc-grpc part. The core FastWARC frames aren't even visible.
image

@krickert

Copy link
Copy Markdown
Author

Yea, I'm messing with chunk sizes over the wire, getting about the 4.8GB/s localhost and over a 10Gb connection we are getting 1GB/s over the wire - which honestly isn't bad.

The idea with the grpc stuff isn't raw speed locally - it's that you can make this a server too. In my case I'm going to use it to take a library of warc files and concurrently run it through a pipeline. I'd have to otherwise do that serialization locally.

I'm still doing some tests though. In-process you won't get a faster result since gRPC is made for remote calls.

  │ Consumer            │ Transport        │ Time                │ Throughput  │
  ├─────────────────────┼──────────────────┼─────────────────────┼─────────────┤
  │ Python FastWARC API │ in-process       │ 0.4s                │ 12346 MiB/s │
  ├─────────────────────┼──────────────────┼─────────────────────┼─────────────┤
  │ Rust gRPC client    │ Unix socket      │ 1.1s                │ ~4700 MiB/s │
  ├─────────────────────┼──────────────────┼─────────────────────┼─────────────┤
  │ Python gRPC client  │ localhost TCP    │ 2.2s                │ 2425 MiB/s  │

So as we move further from the metal, each layer you see a sharp decline. That being said, I thought to myself "if I take the library and just push the output via UDS on the API - that would be a REAL apples to apples.." .. .but that failed for me as well. Streaming the raw reply make it go from 12.6k MBps to 9.1 MBps - a sizable loss but not nearly as bad as gRPC.

So then I realized - the API isn't getting data streaming to it - it is merely getting data already on disk. What if gRPC was in the same playing field, and the server is outputting the results from data on disk:


  ┌──────────────────────────────────────────┬──────┬─────────────┐
  │ Path                                     │ Time │ Throughput  │
  ├──────────────────────────────────────────┼──────┼─────────────┤
  │ Python FastWARC API (in-process)         │ 0.4s │ 12505 MiB/s │
  ├──────────────────────────────────────────┼──────┼─────────────┤
  │ gRPC, file already on disk               │ 0.4s │ 13004 MiB/s │
  ├──────────────────────────────────────────┼──────┼─────────────┤
  │ gRPC, client uploads the 5.3 GiB archive │ 1.3s │ 4143 MiB/s  │
  └──────────────────────────────────────────┴──────┴─────────────┘


So the real tax here is the serialization of the file over the wire. The difference above isn't enough to call gRPC faster (it's faster than JSON but it's not faster than pure rust)...

So last test.. what if I stream it through a live http upload in Rust? Then both have the same latency from what would in theory be over the wire.

Results:

  │ Path                     │ Input                          │ Time │ Throughput  │
  ├──────────────────────────┼────────────────────────────────┼──────┼─────────────┤
  │ FastWARC API             │ open(file)                     │ 0.4s │ 12650 MiB/s │
  ├──────────────────────────┼────────────────────────────────┼──────┼─────────────┤
  │ FastWARC API + HTTP POST │ stream file bytes on localhost │ 1.0s │ 5292 MiB/s  │
  ├──────────────────────────┼────────────────────────────────┼──────┼─────────────┤
  │ gRPC parse-only (UDS)    │ stream file bytes on localhost │ 1.1s │ 4705 MiB/s  │
  └──────────────────────────┴────────────────────────────────┴──────┴─────────────┘

Nearly the same... although edged by FastWARC here...

Anyway, let me organize this and show you numbers you can easily reproduce. But if you add this, I'd run it on AWS standalone.

@phoerious

phoerious commented Aug 14, 2026

Copy link
Copy Markdown
Member

What kind of machine is that and what OS? When I run it here on an Ubuntu machine with a Threadripper 2920X (not the newest, but still a powerful CPU), I get only 300MiB/s throughput both when reading from disk and when reading from a tmpfs. Usually, the latter is significantly faster, unless the file is already fully in the page cache (and often even then). Here I'm getting exactly the same throughput in both scenarios and both are an order of magnitude from what you're measuring or what I get from FastWARC alone.

So then I realized - the API isn't getting data streaming to it - it is merely getting data already on disk. What if gRPC was in the same playing field, and the server is outputting the results from data on disk:

Well, you're certainly limited by your duplex connection, but there's probably also some sender/consumer contention, at least when the server runs in-process. Also keep in mind that if the client isn't also the supplier of the data, you're opening an entirely new security domain. Now you suddenly have to deal with access permissions to prevent streaming of arbitrary protected files from the server system.

- Raise HTTP/2 flow-control windows to 32 MiB connection / 16 MiB
  stream, 1 MiB max frame, TCP_NODELAY, 16 MiB message caps, applied on
  both peers via the new transport module. Adaptive BDP windows stay
  off: they override fixed windows and intermittently stalled streams
  saturated in both directions.
- Decode bulk bytes fields as bytes::Bytes and parse request chunks in
  place through a BufRead ChannelReader, removing the per-chunk Vec and
  BufReader copies on the receive path.
- Pack response events into batch messages (64 events, 2 MiB flush)
  instead of one message per event.
- Reject archive_path with PERMISSION_DENIED unless enabled at startup
  (FASTWARC_GRPC_ALLOW_LOCAL_FILES=1, or WarcParser::with_local_files
  when embedding), with a test.
- Benchmark: parse-only default matching the fastwarc bench, 64 KiB
  upload chunks, FASTWARC_GRPC_JOBS/LOCAL/URL legs, a rawuds raw-socket
  floor binary, and results plus methodology in the README.
@krickert

Copy link
Copy Markdown
Author

tldr-
We're up to 4gb/s and I'm including all testing software I wrote with this commit. That's the best we'll get - if you want to do a direct streaming, I've included those numbers too.

The best takeaway for this is the ability to scale the machine with gzip (makes sense because it minimized the throughput over the wire) - 16 threads on my machine allowed for 16GB/s of processing.

AI slop warning:
https://claude.ai/code/artifact/018f0e96-3ca1-480c-9a91-1941679b0c30

This link provides the report in more detail. (this is a human typing, but that link is all AI including a recommended slop it wanted me to paste to you)

The report does do an exceptional job to show we're now squeezing all we can out of the machine.

@phoerious

phoerious commented Aug 14, 2026

Copy link
Copy Markdown
Member

I'm now getting 927.5 MiB/s with tmpfs and 753.4 MiB/s with SSD read. That's better, but still not what you're reporting. FASTWARC_GRPC_JOBS=16 hangs for a while, then reports 3386.0 MiB/s. Haven't looked into that enough yet to understand what exactly it does and why we cannot get the normal full print output.

FASTWARC_GRPC_JOBS>1 previously printed nothing until the final summary,
which reads as a hang on longer runs. Progress lines now come from
shared counters every 500 ms and cover all streams combined; the
single-stream output format is unchanged.
@krickert

Copy link
Copy Markdown
Author

I'm going to keep trying to optimize the speed further. The speed difference makes sense - my SSDs are a little past the insanity point for speed and the per-core speed is slightly faster (I'd rather have a threadripper though!). I can do a live recording of the timings, it's probably the IO that makes it faster.

I want it to match the speed as much as I can.

Can you try FASTWARC_GRPC_JOBS=12 on the gzip file rather than 16 on uncompressed? Uncompressed jobs hit the memory-bandwidth plateau by design. So this would actually show a good performance use case for grpc.

@phoerious

phoerious commented Aug 14, 2026

Copy link
Copy Markdown
Member

With that, it's reporting 7680.6 MiB/s. But something's wrong. The entire file is about 5GiB uncompressed. So by that statistic, it should have taken around 0.7s to parse it (faster than single-core plain FastWARC), whereas in reality, it took 8.3s.

FASTWARC_GRPC_JOBS=12 ./profile ../../../tmpfs/CC-MAIN-20231005012006-20231005042006-00899.warc.gz
Reading WARC file: ../../../tmpfs/CC-MAIN-20231005012006-20231005042006-00899.warc.gz (parse-only, uds, 64 KiB chunks, 12 job(s), http2 stream 16 MiB / conn 32 MiB)
177247 records/s, 3978.2 MiB/s, 23.0 KiB/rec (88704 total, 1990.9 MiB)
209082 records/s, 5980.6 MiB/s, 29.3 KiB/rec (193440 total, 4986.8 MiB)
176986 records/s, 7613.6 MiB/s, 44.1 KiB/rec (282048 total, 8798.6 MiB)
186078 records/s, 8094.6 MiB/s, 44.5 KiB/rec (375296 total, 12855.0 MiB)
183725 records/s, 8006.5 MiB/s, 44.6 KiB/rec (467296 total, 16864.2 MiB)
184605 records/s, 8161.1 MiB/s, 45.3 KiB/rec (559808 total, 20954.0 MiB)
176372 records/s, 8045.1 MiB/s, 46.7 KiB/rec (648096 total, 24981.2 MiB)
184657 records/s, 7915.9 MiB/s, 43.9 KiB/rec (740608 total, 28947.1 MiB)
181227 records/s, 8101.4 MiB/s, 45.8 KiB/rec (831456 total, 33008.3 MiB)
161990 records/s, 8240.4 MiB/s, 52.1 KiB/rec (912672 total, 37139.7 MiB)
142923 records/s, 8165.2 MiB/s, 58.5 KiB/rec (984192 total, 41225.6 MiB)
147940 records/s, 8514.9 MiB/s, 58.9 KiB/rec (1058272 total, 45489.4 MiB)
136956 records/s, 8312.6 MiB/s, 62.2 KiB/rec (1126880 total, 49653.6 MiB)
141550 records/s, 8163.4 MiB/s, 59.1 KiB/rec (1197792 total, 53743.2 MiB)
147245 records/s, 8078.5 MiB/s, 56.2 KiB/rec (1271680 total, 57797.0 MiB)
141475 records/s, 8596.5 MiB/s, 62.2 KiB/rec (1342434 total, 62096.3 MiB)
Summary: 8.3s, 165653 records/s, 7680.6 MiB/s, 47.5 KiB/rec (1371288 total, 63580.5 MiB)

With FASTWARC_GRPC_JOBS=N each stream parses the full archive, so the
totals cover N times the file. Say so up front and add a per-stream
average line to the summary, so aggregate figures are not mistaken for
single-file wall time.
@krickert

Copy link
Copy Markdown
Author

That's exactly what I expected.. that's 8.3s for 12 jobs.. Yes, it takes longer but the compression has enough CPU free for the gzip IO churning that you end up with being able to recover the overall throughput by running 12 jobs concurrently.

What it means is that concurrency win isn't gRPC vs. local - it's that the wire cost nothing for overall throughput if you stack the streams. Which in a pipeline is exactly how I'd would approach it.

@krickert

Copy link
Copy Markdown
Author

I'm going to test another approach - the gzip format will allow us to multiplex the file into multiple threads for processing and stream the results back concurrently. I think this can solve your gzip issue in general - the file format makes it possible to do.

I'll test that now.

@phoerious

phoerious commented Aug 15, 2026

Copy link
Copy Markdown
Member

That's exactly what I expected.. that's 8.3s for 12 jobs.. Yes, it takes longer but the compression has enough CPU free for the gzip IO churning that you end up with being able to recover the overall throughput by running 12 jobs concurrently.

Yes, but it also means that the per-core speed is still only 640MiB/s. That is fine for Gzip, but it is pretty unacceptable for uncompressed WARCs (even though that's more of an artificial benchmarking scenario). With 12 parallel FastWARC processes, I could probably achieve 60GiB/s, so the numbers aren't comparable. I appreciate that the gRPC server makes it easy to parallelise the streaming. That is useful, but for raw throughput, we should compare single-core performance, otherwise we're comparing apples and oranges.

That said, thanks so far for putting in the work (or letting Claude put it in, whatever). We can probably optimise this more later. My experience from writing FastWARC is that AI is certainly useful to make focused changes and optimisations on your behalf when you know exactly what to expect, but when it comes to squeezing out the last few seconds, it consistently failed. No matter what micro-optimisations it proposed, the resulting code always ran slower than before, never faster. So, optimising FastWARC this far was definitely a lot of manual work and I wouldn't expect this to be different here.

I will start reviewing the rest of the code next week and add comments. Feel free to experiment further in the meantime.

https://claude.ai/code/artifact/9221e2ef-9244-4acb-a539-a0f9a267c98a

Link is dead.

@krickert

Copy link
Copy Markdown
Author

Regarding the claude write up- I reread it and although it read well, but it was grandstanding too much so I took it down. Sorry about that.

I know I can make it faster but I need to first measure more. I have a clear understanding of the socket overhead - which seems to be around 50%. After that, the actual grpc ops are fast. I think it's memory copy churn with creating the grpc responses that are causing it.

I think network is about as good as it can be. With other grpc server solutions, I've often added an HTTP PUT input on the grpc server and always have a streaming output. That would be faster and more effective than a grpc client without. the memory overhead

I'll keep trying..

@krickert

Copy link
Copy Markdown
Author

OK - I think I found a way to make it faster. But I ran this through the entire stack on the machine - and we're up against system IO calls and input starvation.

There is a ton of memory churn, but it's not what's causing the speed problems. It's still worth optimizing 2 layers of it. The tonic codec copies every chunk twice: once into the HTTP/2 frame buffer on send, and once merging frames back into a buffer on receive. Neither copy is required by the wire format, so I added a wire-compatible route that hands the bytes to HTTP/2 by reference and decodes them by reference on the other side. That recovers 10-20% depending on concurrency.

Also if you run the on-disk test multiple times, most of that file lives in the page cache. You're not going to see any of the latency that comes with sockets.

Then the biggest 2 taxes. First, if you put anything over a unix socket, you pay 2 memcopy operations, one into the kernel and one out. There's no way around this, and it's where most of the slowdown lives: feeding fastwarc through a bare socket with no protocol at all already costs ~43% on my machine. Second, input starvation. The socket-fed parser runs at about 90% of what the socket itself can move, so the parser is sitting idle waiting for bytes, and I don't think there's a way around that either.

I'll demonstrate everything above with tests. The good and bad news is that this is about as fast as it gets, because these are system-level limits that on-disk parsing never has to touch. But that's also why parallelizing works: the per-stream socket cost stops mattering once you stack streams, so we can recover most of the overall throughput.

Does that make sense? The tests I'm writing should demonstrate this.

@krickert

Copy link
Copy Markdown
Author

OK I pushed the latest testing suite.. this one should tell you the speed for each layer. I have attached my output.
compare-pr-version.txt

@krickert

Copy link
Copy Markdown
Author

Going to look at this again today too.. I have a little time.

@phoerious phoerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First rough round of reviews.

Comment thread .github/workflows/build-packages.yml Outdated
Comment thread benchmarks/warc/fastwarc-grpc/README.md Outdated
Comment thread fastwarc-grpc/proto/fastwarc/v1/warc.proto Outdated
Comment thread fastwarc-grpc/proto/fastwarc/v1/warc.proto Outdated
Comment thread fastwarc-grpc/proto/fastwarc/v1/warc.proto Outdated
Comment thread fastwarc-grpc/src/warc_service.rs Outdated
Comment thread fastwarc-grpc/tests/data/warcfile.warc.zst Outdated
Comment thread fastwarc-grpc/Cargo.toml Outdated
Comment thread fastwarc-grpc/Cargo.toml Outdated
Comment thread fastwarc-grpc/DESIGN.md Outdated
Every gRPC service in the workspace defaulted to 50051, which collides
when several run on one host. Assign fastwarc-grpc the unique default
port 50061 (FASTWARC_GRPC_ADDR fallback, client/example defaults, and
docs) to avoid collision with sibling services.
Centralize server defaults and hard limits, align the explicit HTTP guard with ArchiveIterator, and document payload-length and batching behavior. Add focused coverage for local-path errors, batch limits, concurrent streams, disabled detection, and omitted payloads.
@phoerious

Copy link
Copy Markdown
Member

Thanks for the changes. I'm away for two weeks. I'll continue reviewing afterwards.

@krickert

Copy link
Copy Markdown
Author

Thanks for the changes. I'm away for two weeks. I'll continue reviewing afterwards.
No rush. I'm genuinely excited about getting this out. I'll keep on looking at it but won't change much from here

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

Successfully merging this pull request may close these issues.

2 participants