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

Add async API #1

Merged
merged 5 commits into from
Apr 24, 2024
Merged

Conversation

CardboardTurkey
Copy link
Contributor

@a-maier this is a really useful crate - thanks for building it! I've got a contribution here to add an async API to the library.

Motivation

Asynchronous programming allows programs to continue doing work when waiting on IO calls, rather than being blocked by them. In the case of hepmc2 it would allow, for example, downstream event generators to continue calculating MEs while waiting for an event to be written to a HepMC file. Given the size of HepMC files that physicists like to generate this could result in sizable performance improvements!

Changes

The first two commits here are minor pieces of house keeping - the meat of the PR begins after that. Please see the commit messages for some guidance on the changes.

In summary, the async API has exactly the same function signatures as the sync one (Future's not withstanding). I managed to avoid almost all code duplication using a combination of maybe_async and a couple of new macros I added to the library. The latter are placed in a new workspace crate called hepcm2-macros (as is standard practice when writing proc macros).

maybe-async

In case you're unfamiliar with it, I can briefly describe the maybe-async crate. It provides an attribute macro maybe_async::maybe_async that should be applied to blocks of async code. In the case where the feature maybe-async/is_sync is enabled, the macro removes all occurrences of the async and await keywords in the block, effectively turning it into sync code. This is how I'm able to use the compiler to generate the sync API from the async one, and so avoiding code duplication.

@a-maier
Copy link
Owner

a-maier commented Jan 29, 2024

Thank you very much!

Support for async is very welcome and has indeed been on my todo list for quite a while. Disclaimer: I would suspect that in most cases the limitation is still parsing/formatting, and not blocking on I/O. To my understanding, other approaches to parallelisation, e.g. rayon, might be slightly more performant.

The first two commits here are minor pieces of house keeping - the meat of the PR begins after that.

Apologies, I am running rustfmt with max_width = 80. There should really be a .rustfmt.toml in the repo. I am happy to either fix this and the clippy lints on my side, or leave it to you. Whichever you prefer.

I am starting to review, but I am afraid it might take a bit longer.

@CardboardTurkey
Copy link
Contributor Author

Apologies, I am running rustfmt with max_width = 80. There should really be a .rustfmt.toml in the repo. I am happy to either fix this and the clippy lints on my side, or leave it to you. Whichever you prefer.

Fixed!

Support for async is very welcome and has indeed been on my todo list for quite a while. Disclaimer: I would suspect that in most cases the limitation is still parsing/formatting, and not blocking on I/O.

True, some profiling would be interesting

To my understanding, other approaches to parallelisation, e.g. rayon, might be slightly more performant.

Possibly, although rayon is most performant with parallelized iterators and I'm not sure that that would work so well in the case of HEP software. You could run an iterator over all events but then you have to hold all those events in memory at once - which would presumably be a lot.

I am starting to review, but I am afraid it might take a bit longer.

Yeah this PR ended up being bigger than I expected. At least a lot of it is just the same changed applied multiple times 😅

Copy link
Owner

@a-maier a-maier left a comment

Choose a reason for hiding this comment

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

Thanks again! For the most part this looks very good to me.

Cargo.toml Outdated Show resolved Hide resolved
hepmc2-macros/Cargo.toml Outdated Show resolved Hide resolved
Readme.md Outdated Show resolved Hide resolved
src/lib.rs Outdated
Comment on lines 12 to 89
//!
//! ```rust,no_run
//! # fn try_main() -> Result<(), Box<dyn std::error::Error>> {
//! // Read events from `events_in.hepmc2` and write them to `events_out.hepmc2`
//! use hepmc2::{Reader, Writer};
//!
//! use std::io::BufReader;
//! use std::fs::File;
//!
//! let input = BufReader::new(File::open("events_in.hepmc2")?);
//! let in_events = Reader::from(input);
//!
//! let output = File::create("events_out.hepmc2")?;
//! let mut writer = Writer::try_from(output)?;
//!
//! for event in in_events {
//! let event = event?;
//! println!("Current cross section: {}", event.xs);
//! writer.write(&event)?
//! }
//! writer.finish()?;
//! # Ok(())
//! # }
//! ```
#![cfg_attr(
feature = "sync",
doc = r##"
```rust,no_run
// Read events from `events_in.hepmc2` and write them to `events_out.hepmc2`
use hepmc2::{Reader, Writer};

use std::io::BufReader;
use std::fs::File;

let input = BufReader::new(File::open("events_in.hepmc2")?);
let in_events = Reader::from(input);

let output = File::create("events_out.hepmc2")?;
let mut writer = Writer::try_from(output)?;

for event in in_events {
let event = event?;
println!("Current cross section: {}", event.xs);
writer.write(&event)?
}
writer.finish()?;
# Ok::<(), Box<dyn std::error::Error>>(())
```"##
)]
#![cfg_attr(
feature = "tokio",
doc = r##"
```rust,no_run
# async fn try_main() -> Result<(), Box<dyn std::error::Error>> {
// Read events from `events_in.hepmc2` and write them to `events_out.hepmc2`
use hepmc2::{Reader, Writer};

use tokio::io::BufReader;
use tokio::fs::File;

let input = BufReader::new(File::open("events_in.hepmc2").await?);
let mut in_events = Reader::from(input);

let output = File::create("events_out.hepmc2").await?;
let mut writer = Writer::try_from(output).await?;

while let Some(event) = in_events.next().await {
let event = event?;
println!("Current cross section: {}", event.xs);
writer.write(&event).await?
}
writer.finish().await?;
# Ok(())
# }
# tokio_test::block_on(async {try_main().await.unwrap()})
```"##
)]

Copy link
Owner

Choose a reason for hiding this comment

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

I think it would make sense to have both the sync and the async example code in the documentation, regardless of which features are enabled. We can then put a #[cfg] inside the examples to make the actual compilation depend on the features.

At the moment, the doc here and the readme are the same - the readme is in fact generated with cargo readme. I would like to keep it that way, i.e. we would copy the new text in the readme over to the doc here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done. Found a way to do this with cfg_attr outside of the examples, which I think I prefer.

I'm afraid though the readme no longer matches cargo-readme - it can't handle doc tests gated behind features: webern/cargo-readme#54

src/writer.rs Outdated
Copy link
Owner

Choose a reason for hiding this comment

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

cargo criterion shows a huge regression (~50%) in the sync write performance after commit 6c56562. Maybe we need wrapper macros that depending on the features forward to either write!/writeln! in sync mode or the format!/write_all combination in async mode.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Now that is concerning! I've been distracted by FOSDEM lately, but will have a think about this now. Wrapper macros might be the way to go if I can find an implementation that isn't too complex

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hi! So I think I've fixed this now, could you confirm?

I followed your suggestion of adding a wrapper macro. I'm happy with this approach but there are two small annoyances:

  1. attribute macros like cfg can only be applied to statements (not expressions), this forced me to include the ? operator within the macro,
  2. I couldn't find a nice way of implementing a writeln! wrapper because there's no async method for writing a line.

@a-maier
Copy link
Owner

a-maier commented Feb 1, 2024

Support for async is very welcome and has indeed been on my todo list for quite a while. Disclaimer: I would suspect that in most cases the limitation is still parsing/formatting, and not blocking on I/O.

True, some profiling would be interesting

It will of course depend very much on the hardware, but in another project I got a huge speedup with synchronous reading of the raw ASCII event records and parallelised parsing through a rayon scope. Again, that was for reading from a local hard disk; event output and network filesystem I/O will be a different beast again.

Apply a `cargo fmt` (with line limit of 80) run and remove redundant
redefinition of `random_state` to sate clippy.

Signed-off-by: Kiran Ostrolenk <[email protected]>
As of 1.34.0 you don't need to embed doc tests in dummy functions just
to use the question mark operator. See
https://doc.rust-lang.org/rustdoc/write-documentation/documentation-tests.html#using--in-doc-tests
for more details.

Signed-off-by: Kiran Ostrolenk <[email protected]>
This adds a `tokio` feature that allows for async writes using
`tokio::io::AsyncWriteExt`. It also adds a `sync` feature, enabled by
default, that exposes the original sync API.

Code duplication between the two APIs is avoided using a combination of
[maybe-async] and the `write_bound` attribute macro defined in
`hepmc2-macros`. The former automatically removes `async`/`await`
keywords for the sync API and the latter is used to choose the correct
trait bounds based on which API is enabled.

[maybe-async]: https://crates.io/crates/maybe-async

A `maybe_async` macro is also introduced to ensure sync writes can
continue using the `std::write` macro and so avoid the performance cost
of allocating intermediate formatted strings.

Signed-off-by: Kiran Ostrolenk <[email protected]>
This follows a very similar logic to the write API.

Signed-off-by: Kiran Ostrolenk <[email protected]>
This adds a section to the README and some doc tests. This also gates
the sync and async doc tests behind their respective features.

Signed-off-by: Kiran Ostrolenk <[email protected]>
Copy link
Owner

@a-maier a-maier left a comment

Choose a reason for hiding this comment

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

Excellent, the performance regression is gone. Thanks very much!

  • As you note, it may be a good idea to implement futures::stream::Stream or whichever equivalent gets stabilised at some point.
  • async benchmarks would be neat.
  • I am trying to think of a more descriptive name for the new maybe_write!.

None of these are major and at least the first two should be separate pull requests anyway.

@a-maier a-maier merged commit e5611f8 into a-maier:master Apr 24, 2024
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