Skip to content

Introduce and use a new compact_size module#3259

Merged
apoelstra merged 1 commit into
rust-bitcoin:masterfrom
tcharding:08-28-compact-size
Sep 10, 2024
Merged

Introduce and use a new compact_size module#3259
apoelstra merged 1 commit into
rust-bitcoin:masterfrom
tcharding:08-28-compact-size

Conversation

@tcharding

@tcharding tcharding commented Aug 28, 2024

Copy link
Copy Markdown
Member

We would like to move the witness module to primitives but there is a bunch of usage of VarInt.

Introduce a module that does the encoding and decoding instead, note that while the functionality is internal decoding returns an error which may one day end up in the public API. So put the module in primitives and make it public.

Adds the module to primitives, adds a public MAX_ENCODABLE_SIZE variable that is commented with an issue link.

#3264

@github-actions github-actions Bot added C-bitcoin PRs modifying the bitcoin crate C-units PRs modifying the units crate labels Aug 28, 2024
@apoelstra

Copy link
Copy Markdown
Member

I don't think we should have a type for this. We decided elsewhere that it should be a method as part of the encoding/decoding traits.

And interesting re MAX_SIZE. Yes, we should start enforcing this. It might even simplify some things here.

@tcharding

Copy link
Copy Markdown
Member Author

I don't think we should have a type for this. We decided elsewhere that it should be a method as part of the encoding/decoding traits.

We need it in Witness and I believe that was not realised when the "have functions as part of encoding/decoding" thing was discussed.

@apoelstra

Copy link
Copy Markdown
Member

How is the usage in Witness different from the usage anywhere else? It looks like we always create this type ephemerally as a wrapper around an integer. It would be just as easy to use function calls.

If we are just trying to match the existing code to minimize the diff, then we shouldn't do the rename.

@tcharding

Copy link
Copy Markdown
Member Author

Oh I follow you, the same logic but just use stand alone functions instead of creating a type. I can try that and see how it looks.

@Kixunil

Kixunil commented Aug 28, 2024

Copy link
Copy Markdown
Collaborator

Exactly, the type is basically over-engineering. I suggest putting these methods to internals:

fn encode_compact_size(num: impl ToU64) -> ArrayVec<u8> { /* ... */ }
fn decode_compact_size(bytes: &mut &[u8]) -> Result<u64, () /* too short */> { /* ... */ }
fn encoded_size_of_compact_size(num: impl ToU64) -> usize { /* ... */ } // technically could be `u8` but that'll likely be annoying.

Comment thread units/src/compact_size.rs Outdated

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Just make this ArrayVec in internals.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I removed this function and returned a [8u; 9].

@github-actions github-actions Bot added C-primitives and removed C-units PRs modifying the units crate labels Aug 28, 2024
@tcharding

Copy link
Copy Markdown
Member Author

I had hacked up a new solution before seeing your review @Kixunil, I'm EOD today, I'll take another look tomorrow.

@tcharding tcharding changed the title Introduce and use a new CompactSize type Introduce and use a new compact_size module Aug 28, 2024
@github-actions github-actions Bot added C-internals PRs modifying the internals crate and removed C-primitives labels Aug 28, 2024
@tcharding tcharding marked this pull request as ready for review August 28, 2024 20:53
@tcharding

Copy link
Copy Markdown
Member Author

Note in this PR compact_size is as required for the witness stuff, it does not include the ToU64 version of encoded_size as done in #2931. I'll do that when I get to the VarInt stuff.

@tcharding

Copy link
Copy Markdown
Member Author

In case its not obvious, this is needed for the work on primitives to progress, please prioritize.

Comment thread internals/src/compact_size.rs Outdated
/// assert_eq!(size, 1);
/// assert_eq!(encoded, [0x20, 0, 0, 0, 0, 0, 0, 0, 0]);
/// ```
pub fn encode(value: u64) -> ([u8; 9], usize) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why not ArrayVec<u8, 9>?

@tcharding tcharding Aug 29, 2024

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

No used, found a bug too - so WIN.

EDIT: Now used.

Comment thread internals/src/compact_size.rs Outdated
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DecodeError {
/// Tried to decode a `CompactSize` from an empty slice.
Empty,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Empty is literally an instance of TooShort, we don't need it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Ha! Conceptually yes but we don't have the expected length until we read the first byte.

(I removed Empty at one stage to find this out :)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Empty means we have less than one. Having less than one means empty.

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.

To make Kix's point more explicit, the "expected minimum length" should be 1.

As for whether we should combine the two variants, I could go either way. I guess the fewer variants the better, especially here where we only have two variants and we could eliminate the whole enum.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

"expected minimum length" should be 1.

Ah, adding "minimum" indeed solves the problem.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Also FTR we could've just wrapped the error.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I'd prefer Result<_, ()> to Option.

To imply its an error case but we don't provide a specific error?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yes, also triggers the must_use warning.

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.

Sorry to pile on, but I'd prefer we use a dummy error type which we mark #[non_exhaustive] so users can't construct it. This will give us the freedom to add information to the error variant in the future.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

internals is unstable anyway, but I'm not opposed to it.

Comment thread internals/src/compact_size.rs Outdated
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TooShortError {
initial_byte: u8,
expected_minimum_length: usize,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

These are redundant since you can compute one from the other.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Who cares? There is no perf hit in having an extra field in an error type, right? Its more brain-dead-simple to carry the redundant info than having more byte -> length conversions.

@Kixunil Kixunil Aug 29, 2024

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Technically, there actually is. Size of error types causes the whole Result to be larger if it's larger than the Ok variant and this slows down Result operations downstream. We generally don't pay too much attention to this since bad errors are worse than bad performance in most cases but if we can reasonably compress the information we should. The return type of the function is u64 which makes your Result ~twice as large as it would be if it was just the value itself. However it'd be really strange if this simple function didn't get inlined which may remove the cost entirely.

And really, the information we need can fit just 4 bits (see below). But I'm not asking you to implement that madness, I'm only asking to fit it into ~u64. :)

Side note: it annoys me that ArrayVec has to use usize for length tracking even if CAP is small enough to fit u8.

4 bits because the errors that can possibly exist are only these:

  • The input is empty
  • The input encodes two-byte requirement and one of the bytes is missing
  • The input encodes four-byte requirement and at most four bytes are missing - four combinations
  • The input encodes eight-byte requirement and at most eight bytes are missing - eight combinations

Which gives us 1 + 1 + 4 + 8 == 14 combination which can be encoded by 4 bits.

Yes, it was a fun puzzle to find the true lower bound and not just any solution that fits into a byte. :)

BTW the most efficient way you can write it in Rust is to enumerate all the possibilities as enum because that also gives niche optimizations.

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.

I didn't read Kix's crazy analysis, but definitely +1 to minimizing error type sizes where it's reasonably possible.

In miniscript we have a massive error types everywhere and it's a big problem for us. It's not so big a problem here but we shouldn't get complacent.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Cool, thanks for the explanation. TIL.

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.

In miniscript we have a massive error types everywhere and it's a big problem for us. It's not so big a problem here but we shouldn't get complacent.

TIL about the result_large_err clippy lint, which fires on errors that are over 128 bytes, but which can be configured to fire at a lower threshold.

Comment thread internals/src/compact_size.rs Outdated
/// let (compact, _) = compact_size::decode(&[0x20, 0xAB, 0xCD]).unwrap();
/// assert_eq!(compact, 32);
/// ```
pub fn decode(slice: &[u8]) -> Result<(u64, usize), DecodeError> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Rather than returning usize and forcing the callers to recompute it's better to take slice as &mut &[u8] and mutate it to update the start of the slice. You can then avoid a bunch of redundant code.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I'll take a look, thanks.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

With fresh eyes I still don't understand what you mean, can you extrapolate please?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This signature is better:

pub fn decode(slice: &mut &[u8]) -> Result<u64, DecodeError>

Where the function updates the slice by moving start by the number of consumed bytes.

Because callers always want to update their positions in the slice, this method is more helpful and doing it manually is annoying and error-prone.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I thought that was what you might be meaning but in both callsites in this PR the caller does not update the slice.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It does, check again.

@tcharding tcharding Sep 1, 2024

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Oh you are right, the iterator does update it. The other callsite (in element_at), does not and does not have a mutable reference to it ether (called from nth). Also decode_cursor does not mutate the slice, I still rekon we should leave it as is. Thoughts?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

All of them mutate the slice in the sense that they re-construct the new one in the exact same way - to start after the compact size bytes. They could as well make a mut bytes copy and mutate it. You could argue we should return (u64, &[u8]) to keep the style functional which is not terrible either, I'm just used to mutating style because of deserializers. Also I'm not sure what the borrow checker will think of it although it's likely fine.

@tcharding

Copy link
Copy Markdown
Member Author

Thanks for the review @Kixunil , I"ll re-spin tomorrow.

@tcharding

Copy link
Copy Markdown
Member Author

Now uses ArrayVec (uncovered a bug also), and removes the error type entirely.

@tcharding

tcharding commented Aug 29, 2024

Copy link
Copy Markdown
Member Author

Another question: While rebasing Steven's work in #2931 on top of this I see that we do checks or non-minimal encoding when decoding a compact size slice, should we be doing that here?

Non-minimal means encoding a value that fits in a u16 using the u32 encoding.

    fn read_compact_size(&mut self) -> Result<u64, Error> {
        match self.read_u8()? {
            0xFF => {
                let x = self.read_u64()?;
                if x < 0x1_0000_0000 {
                    Err(Error::NonMinimalVarInt)
                } else {
                    Ok(x)
                }
            }
            0xFE => {
                let x = self.read_u32()?;
                if x < 0x1_0000 {
                    Err(Error::NonMinimalVarInt)
                } else {
                    Ok(x as u64)
                }
            }
            0xFD => {
                let x = self.read_u16()?;
                if x < 0xFD {
                    Err(Error::NonMinimalVarInt)
                } else {
                    Ok(x as u64)
                }
            }
            n => Ok(n as u64),
        }
    }

@apoelstra

Copy link
Copy Markdown
Member

Yes. In Core these checks are done unconditionally.

@Kixunil

Kixunil commented Sep 3, 2024

Copy link
Copy Markdown
Collaborator

I'd rather remove non-minimality check entirely. It doesn't really matter for Witness. Or at best it should be debug_assert.

@tcharding

tcharding commented Sep 3, 2024

Copy link
Copy Markdown
Member Author

OMG, we are really going around in circles. I can remove the checks but please note that above we agreed that we should have them (see #3259 (comment)).

@tcharding

Copy link
Copy Markdown
Member Author

And they are unconditionally check in Core

template<typename Stream>
uint64_t ReadCompactSize(Stream& is, bool range_check = true)
{
    uint8_t chSize = ser_readdata8(is);
    uint64_t nSizeRet = 0;
    if (chSize < 253)
    {
        nSizeRet = chSize;
    }
    else if (chSize == 253)
    {
        nSizeRet = ser_readdata16(is);
        if (nSizeRet < 253)
            throw std::ios_base::failure("non-canonical ReadCompactSize()");
    }
    else if (chSize == 254)
    {
        nSizeRet = ser_readdata32(is);
        if (nSizeRet < 0x10000u)
            throw std::ios_base::failure("non-canonical ReadCompactSize()");
    }
    else
    {
        nSizeRet = ser_readdata64(is);
        if (nSizeRet < 0x100000000ULL)
            throw std::ios_base::failure("non-canonical ReadCompactSize()");
    }
    if (range_check && nSizeRet > MAX_SIZE) {
        throw std::ios_base::failure("ReadCompactSize(): size too large");
    }
    return nSizeRet;
}

@apoelstra

Copy link
Copy Markdown
Member

@tcharding sure, the checks need to pass -- but there shouldn't be any possible way for them not to pass. This is why a debug_assert would be sufficient.

@tcharding

Copy link
Copy Markdown
Member Author

Added calls to debug_assert to assert minimal encoding. No other changes.

@tcharding

Copy link
Copy Markdown
Member Author

Must be time for lunch, I had <= around the wrong way (should be >).

@Kixunil

Kixunil commented Sep 4, 2024

Copy link
Copy Markdown
Collaborator

@tcharding we agreed to those checks because we didn't notice the functions are not taking outside inputs. Now that we've realized that it's obvious that the checks are just wasted performance. I guess this should be explicitly documented on the functions.

@tcharding

Copy link
Copy Markdown
Member Author

Can we merge as is and follow up now? Docs and things is no bother to add later.

Comment thread internals/src/compact_size.rs Outdated
Comment thread bitcoin/src/blockdata/witness.rs Outdated

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The commit message is stale.

Comment thread internals/src/compact_size.rs Outdated

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could've been ToU64 to simplify the callers.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Ha! Can't call that in const, sadly I had to make the change before I remembered why I didn't use it originally :)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

const trait methods would be awesome.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I just removed the const - we will likely need to add it again later but in this PR its better as you suggest.

@apoelstra

Copy link
Copy Markdown
Member

utACK b9706fca04ed1aee7bc4db91c4868ef7449000eb. This is fine for internal code.

In a later rev we should:

  • Call the decode method _unchecked or something to indicate that it assumes it's receiving a well-formed object
  • (or just fold it into ConsensusDecodable and do the checks, as we eventually intend to do)
  • remove the explicit panics on SIZE and just let the slice indexing panic, to reduce noise
  • make Witness::push and Witness::push_slice fallible if you try to push something exceeding u32's range (actually maybe we have to do this now since it's an API-breaking change)

Also +1 to Kix's comments above -- the commit message refers to primitives not internals and using ToU64 would simplify the caller.

@Kixunil

Kixunil commented Sep 4, 2024

Copy link
Copy Markdown
Collaborator
  • remove the explicit panics on SIZE and just let the slice indexing panic, to reduce noise

That wouldn't reduce noise just make debugging harder. But enums are better anyway.

  • make Witness::push and Witness::push_slice fallible if you try to push something exceeding u32's range (actually maybe we have to do this now since it's an API-breaking change)

Actually it already panics for values larger than u32::MAX and it's not documented because we're storing the indices as u32 rather than usize.

actually maybe we have to do this now since it's an API-breaking change

Definitely before 1.0 release and ideally also before any other release.

We would like to move the witness module to `primitives` but there is
a bunch of usage of `VarInt`.

Introduce a module that does the encoding and decoding instead, this
code is internal so put it in `internals`.

Note we add an unused public `MAX_ENCODABLE_SIZE` variable that is
commented with an issue link. Done like this because its quite
important that we see to it and it makes it clear that we are not and
we know about it.

 rust-bitcoin#3264
@tcharding

Copy link
Copy Markdown
Member Author

The force push is:

  • Re-name to decode_unchecked
  • Use impl ToU64 for encode and encoded_size (removing const from encoded_size)

I checked the range-diff and its easy enough to review. Thanks for your patience on this one, its been trying :)

@tcharding

Copy link
Copy Markdown
Member Author

Bump please, this guy is P-high

@tcharding tcharding changed the title Introduce and use a new compact_size module priority: Introduce and use a new compact_size module Sep 9, 2024

@apoelstra apoelstra 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.

ACK d65de7c successfully ran local tests

@Kixunil Kixunil left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

ACK d65de7c

@tcharding

Copy link
Copy Markdown
Member Author

ooooo nice, I hope your merge script has finished running by the time I'm done the school run!

@apoelstra apoelstra merged commit 060ad58 into rust-bitcoin:master Sep 10, 2024
@apoelstra

Copy link
Copy Markdown
Member

Lol, it had finished but I forgot to type "push".

@tcharding

Copy link
Copy Markdown
Member Author

Yesterdays rust-bitcoind-json-rpc progress was the result of this omission so its not all bad :)

@tcharding tcharding deleted the 08-28-compact-size branch September 12, 2024 01:40
@tcharding tcharding changed the title priority: Introduce and use a new compact_size module Introduce and use a new compact_size module Dec 4, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

C-bitcoin PRs modifying the bitcoin crate C-internals PRs modifying the internals crate P-high High priority

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants