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 an OutputMode type parameter to drive parser results #1631

Merged
merged 40 commits into from
Jun 10, 2023
Merged

Add an OutputMode type parameter to drive parser results #1631

merged 40 commits into from
Jun 10, 2023

Conversation

Geal
Copy link
Collaborator

@Geal Geal commented Jan 29, 2023

this work takes inspiration from chumsky's exploration of generic associated types. In chumsky's PR, output value creation depends on a Mode type parameter to either produce the value, or return (), and the call site decides if it needs the value or not. That mode can be transmitted from parent to child parser, and prevent the compiler from generating the code for an entire chain of results.
As an example, in the delimited combinator, we test tree parsers in sequence, but we only care about the result of the second one. For the other two parsers, we only need to know that they succeeded, we do not need their result, so they might as well not be generated.

This PR extends this solution further, by having a mode parameter for errors too: in a lot of cases, nom combinators just need to know that a parser failed, but do not want to process the error. The OutputMode type also carries a parameter that will drive streaming VS complete behaviour.

TODO:

  • validate conversion of multi combinators
  • validate conversion of branch combinators
  • validate conversion of number combinators
  • rewrite some tests and examples to avoid the .parse syntactic noise
  • test the effect on benchmarks
  • convert the rest of the parsers
  • docs

The Mode trait will be used to adapt the production of output or error
values depending on the call site. If we do not care about the actual
value or error, and just want to know if a parser was succssful or not,
then the Mode trait allows us to signal it without producng the actual
type.

in Err, the Error and Failure variants get different types, because
usually want to get the failure variant, while the error variant might
change independently.

The OutputMode trait also carries information about the streaming or
complete status, and in the same way define which type of parser we
want, directly at the call site
a new `process` method is used to handle the new OutputMode trait.
The `OutputM` supporting structure is used to carry which modes we want
to use, and depending on the case, we can call an inner parser using
directly the mode we're in (example: Map), or we can convert it to Emit
for Output (ex: MapRes because we have to apply the mapping function on
the parser output and check for errors).

We keep the `parse` method with `Emit` for both output and error, which
allows us to convert combinators gradually: the ones using `parse` will
work but won't benefit directly from these traits also parent and child
combinators in a serie of parsers may support it
they are good candidates for this conversion, since some of them ignore
parser results
@coveralls
Copy link

coveralls commented Jan 29, 2023

Pull Request Test Coverage Report for Build 5231826950

Warning: This coverage report may be inaccurate.

This pull request's base commit is no longer the HEAD commit of its target branch. This means it includes changes from outside the original pull request, including, potentially, unrelated coverage changes.

Details

  • 738 of 1136 (64.96%) changed or added relevant lines in 19 files are covered.
  • 145 unchanged lines in 7 files lost coverage.
  • Overall coverage decreased (-0.3%) to 62.127%

Changes Missing Coverage Covered Lines Changed/Added Lines %
src/character/complete.rs 10 12 83.33%
src/character/streaming.rs 10 12 83.33%
benchmarks/benches/http.rs 0 4 0.0%
src/branch/mod.rs 18 22 81.82%
src/sequence/mod.rs 13 18 72.22%
src/bytes/complete.rs 29 35 82.86%
src/number/complete.rs 18 24 75.0%
src/bytes/streaming.rs 29 36 80.56%
src/number/streaming.rs 16 24 66.67%
src/internal.rs 26 41 63.41%
Files with Coverage Reduction New Missed Lines %
src/number/complete.rs 1 65.22%
src/error.rs 2 16.21%
src/lib.rs 3 33.33%
src/multi/mod.rs 3 81.01%
src/internal.rs 11 51.43%
benchmarks/benches/json.rs 21 0.0%
src/traits.rs 104 60.71%
Totals Coverage Status
Change from base Build 4725732019: -0.3%
Covered Lines: 1811
Relevant Lines: 2915

💛 - Coveralls

/// TODO
fn incomplete<E, F: FnOnce() -> E>(needed: Needed, err_f: F) -> Err<E>;
/// TODO
fn is_streaming() -> bool;
Copy link
Contributor

@epage epage Jan 30, 2023

Choose a reason for hiding this comment

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

Something I've been wondering about is if this should have a &self parameter. In most cases, it'll be

#[inline]
fn is_streaming(&self) -> bool {
    false // or `true`
}

and I hope the compiler can optimize away the branch.

But taking &self would allow a BufReader to choose to change states from streaming to complete when the source has been exhausted which might help with some of the gotchas around streaming input.

Granted, that approach is easier when its tied to Input; I haven't thought through how it works with modes.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm not decided on that yet. Right now my thinking is that having that at runtime would generate a lot of code in all cases, while having it static would depend only on the call site: if you want both, you get both implementations, but probably with some code duplication

Comment on lines +364 to +366
fn parse(&mut self, input: Input) -> IResult<Input, Self::Output, Self::Error> {
self.process::<OutputM<Emit, Emit, Streaming>>(input)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Parser::parse is being hard coded to Streaming? That seems odd. I assume I am I missing something about this streaming design

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yes, that part is not written yet. That might end up with two methods, like parse and parse_streaming

Comment on lines +370 to +373
fn process<OM: OutputMode>(
&mut self,
input: Input,
) -> PResult<OM, Input, Self::Output, Self::Error>;
Copy link
Contributor

Choose a reason for hiding this comment

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

test the effect on benchmarks

I'm curious to see if you are able to see the dramatic savings that chumsky did or if some of that is more inherent to their design. My gut feeling is that in a lot of cases, the overhead from a lot of these outputs and error should be pretty low with nom's design as-is and that this wouldn't offer much improvement unless people were going heavy on complex preceded / termianted parsers which json doesn't do and I've not done in my various parsers.

Besides runtime performance, it might also be good to check the impact to binary size or release binaries as this might cause more instances of the generic functions to be generated. I'd also be curious about compile times but I'm assuming that won't be big enough to notice.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

currently most of nom's overhead is in creating values and errors and discarding them, and that will be even more present with heavy error types. So far I have not measured it though, because I have not converted enough code to affect the benchmarks. I'm worried about compilation times, though. That might be a mix of increasing compilation times due to heavy use of impl traits (returning closures was erasing type information), but decreasing it due to less code being generated.

Copy link
Contributor

Choose a reason for hiding this comment

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

currently most of nom's overhead is in creating values and errors and discarding them

Can you qualify this with some more details?

For example, if you are doing delimited(char('['), <something>, char(']')), then I would expect the value wouldn't have much overhead. From the nom examples I've looked at and in my own parsers, this is a fairly common case. Is the overhead more than I expect or is this for more complex usage patterns? chumsky saw the gains on json but I see these more simple cases in nom's json benchmark.

One possible gain is people not discover the _count variant of many parsers. I know I make that mistake. However, that also ties into needing to also pay attention to using terminated / preceded rather than tuples Something I was considering was an alternative trait to Extend that (1) allowed pre-allocation and (2) was implemented for usize and (), replacing _count variants. This is a bottom-up driven approach to not creating expensive values, rather than the Mode's top-down approach. One benefit to top-down is when reusing parsers between throw-away and non-throwaway contexts. So far, I've not needed to do it too much and I would be interested in looking at cases for those that do and how much it impacts performance.

However, I can see it in errors with alt. With combine, I did see performance overhead from their version of alt with heavy errors. On the other hand, I suspect it'd be better to replace it with combine's dispatch. It is a macro that lets you write a match with parsers which cuts down on the number of parse attempts made, reducing overhead further. I can only speak for switching choice / alt to dispatch but it dramatically sped up my code. While this can be done with an imperative parser in nom, I found duplicating dispatch made it more ergonomic which makes it more likely to be used. I suspect some of my speed gains over combine were from finding another place I could insert a dispatch call.

Even with that, I've noticed my performance is sensitive to changes in my error type and I've intentionally kept it simple, putting all of the expensive operations when converting to the public error type for my crate. An opt that doesn't apply can still easily do a single allocation that is thrown away. Dropping that allocation and dropping any of the panic handling around allocations can be beneficial. I was considering a non-GAT approach here as well by making the backtracking error type accepted by opt to be ()). GATs do simplify the process.

@@ -242,7 +361,16 @@ pub trait Parser<Input> {

/// A parser takes in input type, and returns a `Result` containing
/// either the remaining input and the output value, or an error
fn parse(&mut self, input: Input) -> IResult<Input, Self::Output, Self::Error>;
fn parse(&mut self, input: Input) -> IResult<Input, Self::Output, Self::Error> {
Copy link
Contributor

Choose a reason for hiding this comment

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

Won't modal parsers wrapping non-modal parsers lose some of the benefit?

Example: If I have a preceded((parser1, cut(parser2)), parser3). preceded does a Check on (parser1, parser2). Regardless of what the tuple impl does, cut just calls parse which switches the mode from Check to Emit

Depending on how concerned you are about this problem, it almost seems like the existence of parse makes it easy for people to call the wrong thing and people should call process but processs API is more complicated for end-users who don't want to worry about this stuff.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yes, to work properly, the entire chain, at least in what nom provides, should support it. I'd like to see if converting the existing way of writing parsers (functions returning closures) to this would not be too noisy

src/internal.rs Outdated
Comment on lines 480 to 488

fn process<OM: OutputMode>(&mut self, i: I) -> PResult<OM, I, Self::Output, Self::Error> {
let ($(ref mut $parser),+,) = *self;

$(let(i, $output) = $parser.parse(i)?;)+
// FIXME: is there a way to avoid producing the output values?
$(let(i, $output) = $parser.process::<OutputM<Emit, OM::Error, OM::Incomplete>>(i)?;)+

Ok((i, ($($output),+,)))
// ???
Ok((i, OM::Output::bind(|| ($($output),+,))))
Copy link
Contributor

Choose a reason for hiding this comment

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

Naively, I would assume you could pass the mode along which would end up with ((), (), ()) for Check and then bind would turn that into ().

However, i assume you tried that. What problem did you run into?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I haven't fixed that one yet, passing the mode along should be enough, but ideally I'd like a way to selectively discard some tuple elements as you said in winnow-rs/winnow#82

@@ -343,12 +477,15 @@ macro_rules! impl_parser_for_tuple {
{
type Output = ($($output),+,);
type Error = E;
fn parse(&mut self, i: I) -> IResult<I, ($($output),+,), E> {

fn process<OM: OutputMode>(&mut self, i: I) -> PResult<OM, I, Self::Output, Self::Error> {
Copy link
Contributor

Choose a reason for hiding this comment

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

Side note: I feel like adding Check support further highlights the problem in winnow-rs/winnow#82.

@Geal
Copy link
Collaborator Author

Geal commented Jan 30, 2023

@epage this is still pretty early, so don't overindex on the current state of the code and API, I'm still exploring how it can work in the end

@epage
Copy link
Contributor

epage commented Jan 30, 2023

@epage this is still pretty early, so don't overindex on the current state of the code and API, I'm still exploring how it can work in the end

I understand and am commenting now in case any of my thoughts are helpful for that exploratory process as I've been thinking about parallel problems. If its not helpful at this stage, I can back off.

src/multi/mod.rs Outdated
Comment on lines 61 to 74
pub fn many0<I, F>(
mut f: F,
) -> impl FnMut(I) -> IResult<I, Vec<<F as Parser<I>>::Output>, <F as Parser<I>>::Error>
f: F,
) -> impl Parser<I, Output = Vec<<F as Parser<I>>::Output>, Error = <F as Parser<I>>::Error>
where
I: Clone + InputLength,
F: Parser<I>,
{
move |mut i: I| {
let mut acc = crate::lib::std::vec::Vec::with_capacity(4);
Many0 { parser: f }
}

/// Prser implementation for the [many0] combinator
pub struct Many0<F> {
parser: F,
}
Copy link
Contributor

@epage epage Jan 30, 2023

Choose a reason for hiding this comment

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

aside: In winnow-rs/winnow#6, I gave up on moving the parsers to be structs in part because of type inference issues. Looking back at the errors, it looks like it all came down to my PhantomData:

error[E0282]: type annotations needed
Error:     --> src/number/complete.rs:1411:9
     |
1411 |         map(tuple((digit1, opt(pair(char('.'), opt(digit1))))), |_| ()),
     |         ^^^ cannot infer type of the type parameter `PO` declared on the function `map`
     |
help: consider specifying the generic arguments
     |
1411 |         map::<_, PO, _, I, O, E>(tuple((digit1, opt(pair(char('.'), opt(digit1))))), |_| ()),
     |            +++++++++++++++++++++

(PO, I, O, and E were PhantomData fields)

I suspect #1626 would help with this type inference problem and, now looking at it, I think it does, making life easier on this PR than it was for me on winnow-rs/winnow#6.

@Geal
Copy link
Collaborator Author

Geal commented Feb 5, 2023

it's able to run the JSON parser now (not fully, still missing the float parsing), and so far it looks slower (between 3% and 16%). I do not know yet where that comes from, I suspect that either there's an issue with recursin, or that some parts are not properly inlined. The UX looks ok, it requires adding a .parse() here and there but it would not be too traumatic to convert a nom 7 parser, and it's possible to convert selected parts of a parser as needed, while keeping the functions everywhere else.

Writing an implementation of Parser::process is a bit annoying though, I'll spend more time on UX

@epage
Copy link
Contributor

epage commented Feb 5, 2023

I've been finding the json benchmark is too trivial to get reasonable numbers from it, so I've added a larger one: winnow-rs/winnow@1a48893

FYI the dispatch! macro dropped json parse time from 12ms to 9ms for the large file: winnow-rs/winnow#119

@Geal
Copy link
Collaborator Author

Geal commented Feb 5, 2023

Yes, I've been testing with the canada file too, that's where I get a 3% difference.
dispatch is a good idea, I wrote similar switches in other parsers but never took the time to add it back.
FWIW the goal of that benchmark was not to make the fastest json parser, but to check the evolution between nom versions with a naive parser, so that's why I have not optimized it much

@Geal
Copy link
Collaborator Author

Geal commented Feb 5, 2023

flamegraph of execution from main:

flamegraph-main-no-gat

flamegraph of execution from this PR:

flamegraph-gat

@epage
Copy link
Contributor

epage commented Feb 6, 2023

dispatch is a good idea, I wrote similar switches in other parsers but never took the time to add it back.

Ah, I should have clarified. As dispatch! is probably the closest to the idealized result of GAT, the benchmark I ran provides an theoretical lower limit on parse times for the alt calls I replaced.

FWIW the goal of that benchmark was not to make the fastest json parser, but to check the evolution between nom versions with a naive parser, so that's why I have not optimized it much

Yes, I've kept a naive version which is what I'd highlight in winnow's cookbook. I also am considering writing an optimization guide that show cases the various stages of optimization and report the benchmark results so people can see the relative impact of various changes.

@epage
Copy link
Contributor

epage commented Feb 6, 2023

Are your benchmarks with Error or VerboseError? While I'm surprised by a slow down, I would expect more of a speed up with an error type that does more processing.

@Geal Geal marked this pull request as ready for review June 10, 2023 21:15
@Geal Geal merged commit 90d78d6 into main Jun 10, 2023
28 checks passed
@Geal Geal deleted the gat branch June 10, 2023 21:16
@Geal Geal restored the gat branch June 10, 2023 21:16
@tisonkun
Copy link

It seems customized parser become harder after this patch.

Previously works:

fn comma_separated_list1<'a, T>(
    f: impl FnMut(Input<'a>) -> ParseResult<'a, T>,
) -> impl FnMut(Input<'a>) -> ParseResult<'a, Vec<T>> {
    separated_list1(Comma, f)
}

Now breaks:

error[E0277]: expected a `FnMut<(&'a [Token<'a>],)>` closure, found `impl Parser<&[Token<'_>], Output = Vec<<impl FnMut(Input<'a>) -> ParseResult<'a, T> as Parser<&[Token<'_>]>>::Output>, Error = parser::error::ParseError>`
   |
67 | ) -> impl FnMut(Input<'a>) -> ParseResult<'a, Vec<T>> {
   |      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected an `FnMut<(&'a [Token<'a>],)>` closure, found `impl Parser<&[Token<'_>], Output = Vec<<impl FnMut(Input<'a>) -> ParseResult<'a, T> as Parser<&[Token<'_>]>>::Output>, Error = parser::error::ParseError>`
68 |     separated_list1(Comma, f)
   |     ------------------------- return type was inferred to be `impl Parser<&[Token<'_>], Output = Vec<<impl FnMut(Input<'a>) -> ParseResult<'a, T> as Parser<&[Token<'_>]>>::Output>, Error = ParseError>` here
   |
   = help: the trait `FnMut<(&'a [Token<'a>],)>` is not implemented for `impl Parser<&[Token<'_>], Output = Vec<<impl FnMut(Input<'a>) -> ParseResult<'a, T> as Parser<&[Token<'_>]>>::Output>, Error = parser::error::ParseError>`

@epage
Copy link
Contributor

epage commented Jun 23, 2023

If you switch the impl FnMut to impl Parser, I'm assuming it'll be resolved

@tisonkun
Copy link

tisonkun commented Jun 23, 2023

@epage you're right. I actually change the return type when debugging, but not for the argument.

After changing the argument also, it seems work now:

fn comma_separated_list1<'a, T>(
    f: impl Parser<Input<'a>, Output = T, Error = ParseError>,
) -> impl Parser<Input<'a>, Output = Vec<T>, Error = ParseError> {
    separated_list1(Comma, f)
}

... while I may introduce a local trait bound to reduce the longy type name.

UPDATE - Here is the trait alias:

pub(self) trait ParserBound<'a, O> = Parser<Input<'a>, Output = O, Error = ParseError>;

... but a trait bound doesn't work:

pub(self) trait ParserBound<'a, O>:
    Parser<Input<'a>, Output = O, Error = ParseError>
{
}
error[E0277]: the trait bound `impl Parser<&[Token<'_>], Output = Vec<<impl ParserBound<'a, T> as Parser<&[Token<'_>]>>::Output>, Error = parser::error::ParseError>: ParserBound<'a, Vec<T>>` is not satisfied
  --> src/database/src/sql/parser/parse/mod.rs:69:64
   |
69 | fn comma_separated_list1<'a, T>(f: impl ParserBound<'a, T>) -> impl ParserBound<'a, Vec<T>> {
   |                                                                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `ParserBound<'a, Vec<T>>` is not implemented for `impl Parser<&[Token<'_>], Output = Vec<<impl ParserBound<'a, T> as Parser<&[Token<'_>]>>::Output>, Error = parser::error::ParseError>`
70 |     separated_list1(Comma, f)
   |     ------------------------- return type was inferred to be `impl Parser<&[Token<'_>], Output = Vec<<impl ParserBound<'a, T> as Parser<&[Token<'_>]>>::Output>, Error = ParseError>` here

@epage
Copy link
Contributor

epage commented Jun 23, 2023

For trait ParserBound, did you add a blanket impl for Parser<Input<'a>, Output = O, Error = ParseError>?

@tisonkun
Copy link

blanket impl

I think I should do something like that. But actually I don't know what does it mean and how to do 🤣

@epage
Copy link
Contributor

epage commented Jun 23, 2023

pub(self) trait ParserBound<'a, O>:
    Parser<Input<'a>, Output = O, Error = ParseError>
{
}

impl<'a, P, O> ParserBound<'a, O> for P
where
    P: Parser<Input<'a>, Output = O, Error = ParseError>
{
}

@tisonkun
Copy link

@epage Thank you! It works.

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.

None yet

4 participants