Conversation
|
One other solution is try to "give back" the consumed bytes, which is not practical in most implementations, but could be also achieved by chaining readers. Say: let header = reader.take_array::<8>()?;
if heder_is_foo(&header) {
variant1(reader);
} else {
let backtracked = Reader::chain(header, reader);
variant2(backtracked);
}This might impose slight perf penalty though. |
Yeah, this has the unfortunate consequence that the I think we either need to further flesh out the design proposed in this PR, revert deprecations on the buffering methods, or come up with another solution that doesn't impose a performance regression. Also realizing now that |
|
One other idea is to just embed the seeded function in existing trait SchmeaRead {
type Seed = ();
fn decode_seeded(seed: Self::Seed, reader: impl Reader) -> ReadResult<Self::Dst> {
Self::decode(reader)
}
}or |
|
And for the name, I'm not sure if seed is that good, it sounds like something that pre-determines the whole value (like in seeded random generator), and here we are simply dealing with partially read value or more concretely a header. And if we think this will always be a prefix of the encoded bytes that are part of the "seed", then I would prefer to just use "header". |
I like this direction, but making We could add an additional generic "context" type parameter to pub unsafe trait SchemaRead<'de, C: ConfigCore, Ctx = ()> {
// ...
fn read_with_context(
ctx: Ctx,
reader: impl Reader<'de>,
dst: &mut MaybeUninit<Self::Dst>,
) -> ReadResult<()> {
Self::read(reader, dst)
}
}This has the following advantages:
Points 2 and 3 imply that even if one uses the derive macro (which will maintain the default), one can still provide a specific implementation with whatever context is appropriate for their use-case. This means we can also provide a Vec implementation like the following without conflicts on the existing implementation: unsafe impl<'de, T, C: ConfigCore> SchemaRead<'de, C, usize> for Vec<T>Such that the following is possible: Vec::get_with_context(usize) |
5ae3e02 to
e1312fa
Compare
SchemaReadState to better support non-standard schemesCtx to SchemaRead
|
Having some form of context-enabled (or maybe stateful, with A few more thoughts here:
|
| } | ||
|
|
||
| #[cfg(feature = "alloc")] | ||
| unsafe impl<'de, T, Len, C: ConfigCore> SchemaRead<'de, C> for Vec<T, Len> |
There was a problem hiding this comment.
wincode::schema::containers::Vec is a public, custom type providing wincode-related functionality. Could we for now simply add some public APIs there that will enable deserialzing alloc::Vec with a known len? If we hold on with elevating it to generic API, it can still serve immediate need, right?
There was a problem hiding this comment.
It can solve the Vec case, but doesn't help in the VersionedMessage case. We'll have to perform manual deserialization here
There was a problem hiding this comment.
Looking now at VersionedTransaction -> VersionedMessage case:
- context won't solve the issue of
// Legacy or V0 transactionbranch - note that you will have 1 byte consumed and signatures is aVec<Signature>, the partial context of decoding vector in this case doesn't align withlen, which needs more bytes. You will need to do manual parsing of len with 1 byte provided and others coming from the reader, and then use the context-aware parsing of Vec - seems like this case can be solved by keeping
peek_bytein the reader's API. This seems totally doable without compromising the buffered reader cleanup gains: 1 byte peek is available / easy to support in any imaginableReaderimplementation VersionedMessage, unlike signatures vector, luckily do have a clear "context" matching the 1-byte that would be read independently (== 1 bytes denominator), but I think in general this example (with signatures in variant of transaction) highlights that flexible parsing relies on raw access to bytes at arbitrary (logic dependent) cutoff and not always on parsed units matching specific fields of the decoded struct (as shown in some derive-level proposals), so in practice context will sometimes be raw array of bytes - I guess this is ok, Context is a generic concept, which can be anything, it just makes the derive feature less attractive- Interestingly in https://github.com/anza-xyz/solana-sdk/blob/556aa4442495047b8222cf4c921e367156d3a59d/message/src/versions/mod.rs#L430 we actually do what you mention as undesirable consequence of removing
fill_buf/peek, the first byte is consumed, not peeked and then we indeed do manual deserialization of remaining fields. Possibly that code could be improved bypeek_byte+ use auto-generated deserialization of messages per-variant (there might be other issues I can't see, the logic is a bit complicated, but.. seems to be working https://github.com/anza-xyz/solana-sdk/pull/616/changes)
There was a problem hiding this comment.
- context won't solve the issue of
// Legacy or V0 transactionbranch - note that you will have 1 byte consumed and signatures is aVec<Signature>, the partial context of decoding vector in this case doesn't align withlen, which needs more bytes. You will need to do manual parsing of len with 1 byte provided and others coming from the reader, and then use the context-aware parsing of Vec- seems like this case can be solved by keeping
peek_bytein the reader's API. This seems totally doable without compromising the buffered reader cleanup gains: 1 byte peek is available / easy to support in any imaginableReaderimplementation
Still solvable with context. We'd just need to add a context variant for ShortU16. But certainly true that requiring a buffered backend makes this simpler as we can just peek.
VersionedMessage, unlike signatures vector, luckily do have a clear "context" matching the 1-byte that would be read independently (== 1 bytes denominator), but I think in general this example (with signatures in variant of transaction) highlights that flexible parsing relies on raw access to bytes at arbitrary (logic dependent) cutoff and not always on parsed units matching specific fields of the decoded struct (as shown in some derive-level proposals), so in practice context will sometimes be raw array of bytes - I guess this is ok, Context is a generic concept, which can be anything, it just makes the derive feature less attractive- Interestingly in https://github.com/anza-xyz/solana-sdk/blob/556aa4442495047b8222cf4c921e367156d3a59d/message/src/versions/mod.rs#L430 we actually do what you mention as undesirable consequence of removing
fill_buf/peek, the first byte is consumed, not peeked and then we indeed do manual deserialization of remaining fields. Possibly that code could be improved bypeek_byte+ use auto-generated deserialization of messages per-variant (there might be other issues I can't see, the logic is a bit complicated, but.. seems to be working https://github.com/anza-xyz/solana-sdk/pull/616/changes)
Yep -- all true. This is why I've been an advocate for requiring a buffered backend. There are a number of cases where it's just significantly simpler to be able to inspect bytes before consuming them. The downside with peeking is that you incur extra bounds checks that would otherwise be avoided if you parsed incrementally and leveraged already-read bytes in the form of the proposed context system or manual deserialization (e.g., with the builder in the VersionedMessage you pointed out) -- which in practice should be trivial in terms of performance vs simplicity trade-off. You also lose compatibility with a true streaming reader (e.g., std::io::Read) -- you need buffering, but I'm personally fine with that.
In summary I still think all cases we're discussing are solvable with the context system if we want to strip down the Reader trait as we've been discussing. The trade off is simpler, and more widely implementable Reader implementations and a modest removal of bounds-checks in some cases for much more flexiblity and reduced boilerplate in some cases
It actually wouldn't require changing the call-sites to wincode/wincode/src/schema/containers.rs Lines 277 to 281 in e1312fa
I don't think
This is definitely a reasonable stopgap worth considering.
It actually does generalize to other cases. Here's another example: Here, the first byte is either a version discriminant or
I do think the context system we're discussing here is a good, generalized solution that is minimally invasive. It won't break downstream code or introduce additional complexity unless users want to opt into the functionality. Even then, the compiler is able to infer the right thing when the destination is typed, so doesn't necessarily always introduce typing complexity. I also think your idea of providing a single utility function for deserializing a slice is a reasonable stop-gap as well. Though, it doesn't generalize to the |
Ah, right, the decoded bytes don't overlap. Technically then this specific case can be solved as let entries: Vec<Entry> =
<WincodeVec<Entry, MaxDataShredsLen> as SchemaRead<'de, C>>::get(reader)?;
if entries.is_empty() {
// only len was deserialized and it turned to be 0
<VersionedBlockMarker as SchemaRead<C>>::get(reader)?
} else {
dst.write(Self::EntryBatch(entries));
}
I see, though using context would only allow moving the manual deserialization of remaining parts to the dedicated implementation (from having it spread out in the branches of one function), not eliminate it, right? One more supplementing idea here - maybe uninit builder should have a function like "read the rest" that will go over fields that were not yet initialized, in original order and deserialize them using provided reader (it would in fact be identical to the regular schema read deserialization except that each field reading would be gated by initialized bit).
Right, I agree it can express useful cases cleanly. I'm trying to weigh the net benefit though - the conditional deserialization that is done manually doesn't necessarily look too bad and I have an impression automating that part (flexible derive support) would be tricky. I will analyze the provided examples a bit more. |
Yeah, you're right. Definitely could solve this case with the above.
Right. It would just allow localizing the partial deserialization with context into a dedicated mechanism. We could probably devise some derive attribute scheme that could generate them. Though, for I think the main challenge with derive macro attributes would be figuring out how to the right syntax for expressing context matricies. E.g., #[derive(SchemaRead)]
struct Foo {
#[wincode(ctx)]
bar: u32,
#[wincode(ctx)]
baz: bool,
}Does this generate Or would we want to force expressing all desired permutations? #[derive(SchemaRead)]
// Generate all 3
#[wincode(ctx(bar, baz, (bar, baz))]
struct Foo {
bar: u32,
baz: bool,
}
#[derive(SchemaRead)]
// Generate just for bar
#[wincode(ctx(bar))]
struct Foo {
bar: u32,
baz: bool,
}^ This one actually seems fairly reasonable to me.
Only downside I see to this is that initialization bits are stored at runtime, so an implementation of "read the rest" would entail quite a bit of branching. Whereas the context system defines these as separate implementations at compile time |
|
Let's try to figure out if there are other blocking examples where it isn't enough to:
It feels like we are trying to decide if supporting LL(1)-style parsing (like in https://en.wikipedia.org/wiki/LL_parser) is enough or we need something that stores arbitrary context. I think context could be a generic and useful concept for different purposes, for example using |
My sense is that
I'm not necessarily worried about "rushing" this design. It's robust enough to handle the cases we've been discussing and has the advantage that it's purely additive in terms of functionality (i.e., it doesn't necessarily add complexity for the typical use-case and doesn't break existing code or require a change to the derive macro). We're still pre v1.0, which gives us license to experiment, and doesn't lock us into a design long-term. I agree in principle we shouldn't rush designs, but out of all the options we've explored to accommodate removal of implicit-buffering methods on
|
Good point, if Context could be I'm onboard with this change, but I think the reader's peeking functions are more straight-forward ways to achieve the typical use case. Let's have this change in 0.5, ideally with some more motivating examples in form of:
|
Ok, sounds good. We'll ship the minimal |
Problem
With the removal of buffering methods on
Reader, likefill_buf,peak, etc, certain "non-standard"SchemaReadimplementations become very cumbersome to implement, or force the implementer to re-implement basic wincode functionality.In particular, some implementations may require inspecting some set of bytes before choosing a deserialization path. Without the aforementioned buffer-style
Readermethods, this is effectively impossible with the current set of wincode built-ins, unless the user re-implement core deserialization primitives themselves.Here is one such example:
https://github.com/anza-xyz/agave/blob/fb34650653dbd84de5edddb46d7f8303cacac0d3/entry/src/block_component.rs#L519-L543
In this example, the first 8 bytes are effectively used as an ad-hoc discriminant. If they are
0u64, those bytes should be discarded and the function should proceed to deserialize aVersionedBlockMarker. Otherwise, those bytes represent the length of aVec<Entry>. If the user were to usetake_array::<8>(), the reader will advance by 8 bytes, and the subsequent deserialization ofVec<Entry>will fail or be incorrect due to a missing length. In this example, the only remediation is to re-implementVec's deserialization logic at the call site.Here is another example:
https://github.com/anza-xyz/solana-sdk/blob/556aa4442495047b8222cf4c921e367156d3a59d/transaction/src/versioned/mod.rs#L304-L329
Here, the first byte is used as a discriminant to choose the appropriate execution path. That first byte is used as input in both paths. Similar to the previous example, if the user is forced to use
take_byte(), they need to manually implement parts of the deserialization logic.One (possible) solution
If we want to maintain the new, trimmed down,
ReaderAPI proposed in #213, one possible solution is a new trait,SchemaReadState. The API is identical toSchemaRead, but importantly, takes a&mut selfreceiver. This allows pre-populating some deserialization state before proceeding with deserialization. This abstraction generalizes to use-cases outside of justVec, but the downside is that it does require more manual implementation work for the implementer.The idea
The basic idea is to support deserialization use-cases where certain parts of the deserialization state is already read in an earlier phase of deserialization.
Consider the following type, which allows pre-populating a pre-read
Veclength.This is a generalization of the
VecSchemaReadimplementation -- its implementation is parameterized by a length, rather than it being an implicit part ofVec's deserialization logic.This allows the
Vecimplementation to simply call it directly after deserializing the length:An interesting possibility
An interesting possibility of this design is a more robust derive system for custom serialization schemes.
A hypothetical example:
where
state_inputindicates the annotated type is aSchemaReadStatewhose construction input is the name of the field.The actual implementation details of this kind of functionality is likely fairly involved or less straightforward than the example code suggests, but interesting to contemplate nonetheless.
The downside
The downsides with this approach are increased API surface area and an increase in complexity for implementers. Rather than being able to have pretty much full control over conditional deserialization logic and payload inspection, implementers are confined to the boundaries of either
SchemaReadorSchemaReadState. We likely would want to add*Statevariations for all collection types for completeness, and potentially other types that I haven't yet considered.Summary
The problem described is a real problem that we will have to figure out how to deal with immediately if #213 goes in without any supplementing functionality.
I think
SchemaReadStateis a reasonable solution, but it does have downsides in terms of ergonomics and maintenance burden.Another possible remediation would be to re-introduce
Readerbuffer-style methods, but make them optional, similar to what is already proposed withtake_scoped. I personally lean this way because of the flexibility it offers for users.SchemaReadStatemay not work for certain edge cases not yet considered, and it requires a lot of setup / ceremony that the buffering methods don't. But, would like to hear other thoughts / ideas.