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

Integer size and overflow when calculating penalty #247

Closed
robinkrahl opened this issue Dec 6, 2020 · 17 comments · Fixed by #421
Closed

Integer size and overflow when calculating penalty #247

robinkrahl opened this issue Dec 6, 2020 · 17 comments · Fixed by #421
Labels

Comments

@robinkrahl
Copy link
Contributor

When testing the optimal-fit algorithm, I encountered a panic during the cost calculations:

thread 'main' panicked at 'attempt to multiply with overflow', .../textwrap-0.13.0/src/core.rs:710:21

I fixed this by multiplying my mm widths with the factor 100 instead of 1000, which should still be a sufficient precision. But looking at the cost calculations, I have two follow-up questions:

  • Why are the costs stored as a i32 instead of a usize? They are generated by adding positive usize values, positive integer constants and other costs, so usize should work too.
  • How should overflows be handled when calculating the penalty? I changed the i32 type to usize, causing an integer overflow at the same location for the max_width test case that sets the line width to usize::MAX. This is currently working, probably because the huge value target_width - line_width is cropped when casting to i32 before updating the cost. Shouldn’t the cost calculation use saturating additions and multiplications if large line widths are supported?
@mgeisler
Copy link
Owner

mgeisler commented Dec 6, 2020

Hey @robinkrahl,

Uh oh... thanks for checking the boundaries! I did some back of the envelope calculations and figured that gap * gap should fit in an i32, at least for normal line lengths in a terminal.

  • Why are the costs stored as a i32 instead of a usize? They are generated by adding positive usize values, positive integer constants and other costs, so usize should work too.

Yes, I think you're right... while developing it, I was playing negative penalties too, just to see what the effect was. I'm afraid there's no further cleverness behind the numbers than that 😄

Shouldn’t the cost calculation use saturating additions and multiplications if large line widths are supported?

I am unfortunately not 100% sure if it works to return a large fixed value such as i32::MAX. The Python implementation of the SMAWK algorithm says

Matrix(i,j) should always return a value, rather than raising an
exception, even for j larger than the range we expect to compute.
If j is out of range, a suitable value to return that will not
violate concavity is Matrix(i,j) = -i. It will not work correctly
to return a flag value such as None for large j, because the ties
formed by the equalities among such flags may violate concavity.

The cost returned for the pair (i, j) defines the matrix mentioned above. This matrix must be totally monotone, which can be checked by smawk::is_monge. This checks a stronger property than 'concavity" or "total monotonicity" mentioned above. I've verified that the matrix formed by the current penalties of type i32 form a matrix of the correct form — I haven't yet checked if it also works if the matrix has a region with constant values.

@mgeisler
Copy link
Owner

I've been testing this and as I figured, it doesn't work to simply use saturating arithmetic. As an example, I truncated the cost at 15,000 and wrapped "xx yyy zzzz" at a width of 100. The resulting cost matrix looks like this:

[     0,  10604,   9836,   1000]
[     0,      0,  15000,  11604]
[     0,      0,      0,  10861]
[     0,      0,      0,      0]

The condition for being totally monotone says that top_left + bot_right <= bot_left + top_right must hold for all the small 2 × 2 sub-matrices. We only have the matrix available above the diagonal, but we can see that the top-right corner is off:

\\ top_left + bot_right =  9836 + 11604 = 21440
// bot_left + top_right = 15000 +  1000 = 16000

The anti-diagonal is too small since the bottom-left corner got truncated from 21,013 to 15,000.

I'll try and figure out if there is some way of replacing "overflow" in the matrix with a bounded number which still satisfies the condition of monotonicity.

It might be that the easiest thing is to say "don't use more than n/2 - 1 of the bits for the width"... If using u64 internally, I think widths up to 31-bits work since we the cost is gap * gap + previous_minima. I still have to verify and test this, though.

@mgeisler
Copy link
Owner

Some more observations. I ran cargo fuzz on the crate and found that this simple case is enough to trigger an overflow:

wrap("x y z", u32::max_value() as usize)

However, I don't see overflows if I use u32::max_value() / 2 as the width.

The above is with usize (u64) as the type used for the internal computations. If I use u128 instead, then I see the same: using u64::max_value() as the width fails on the input "x y z". Again, I cannot find a bad input which triggers overflow when the width is u64::max_value() / 2.

mgeisler added a commit that referenced this issue Dec 18, 2020
These fuzz tests immediately found the problem reported in #247 and
gave a short way of reproducing it:

    wrap("x y z", usize::max_value())

will currently panic due to an overflow error. The wrap_first_fit
function seems to not crash.

Run the fuzz tests with:

    $ cargo fuzz run fill_optimal_fit -- -only_ascii=1

You’ll need to `cargo install cargo-fuzz` first.
mgeisler added a commit that referenced this issue Dec 18, 2020
These fuzz tests immediately found the problem reported in #247 and
gave a short way of reproducing it:

    wrap("x y", 515566821223)

will currently panic due to an overflow error. The wrap_first_fit
function seems to not crash.

Run the fuzz tests with:

    $ cargo fuzz run fill_optimal_fit -- -only_ascii=1

You’ll need to `cargo install cargo-fuzz` first.
mgeisler added a commit that referenced this issue Dec 26, 2020
The `wrap_optimal_fit algorithm` computes the penalty for a gap as
`gap * gap`. If a fragment has a size near `usize::max_value()` and if
the line width is small, this computation can easily overflow.

When this happened, we would previously abort or unwind. Now, we
instead do the computations with checked arithmetic and detect the
overflow. We then proceed to wrap the half of the fragments by
themselves. If this work, we then wrap the second half. This way, we
might be able to wrap everything without overflow.

Should there be a single fragment which causes the overflow by itself,
this fragment is put on a line by itself.

When wrapping part of the fragments, we might of course end up with a
partial last line. To fix this, we simply pop this line and re-wrap
the fragments that were put onto this line. This ensures no “seams” in
the wrapping.

Fixes #247.
@mgeisler
Copy link
Owner

Hi @robinkrahl, I've looked at this a lot and I've tried a few different approaches. I think I've found one that works, albeit with some performance loss.

Basically, I switched to checked arithmetic so I can detect when the computations overflow. When that happens, I split the list of fragments into two and wrap each half separately before joining the results. This should give the best possible results in the face of "over-sized" fragments. It is about 15-20% slower than before, though.

Some questions come to mind:

  • We talked about allowing the use of a custom "width" type at some point. This used to be fairly simple: just give me a type that implements basic arithmetic. Now the type will need to implement checked arithmetic instead. I guess num-traits might end up becoming a dependency — I had hoped to avoid more mandatory dependencies.
  • Is this actually better for you than a simple panic!("Please don't use words longer than usize/4") at the top of the wrap_optimal_fit function? I'm not 100% sure what the precise bound is there, though, but would that be equally useful to you?

In any case, I'll put up the branch now and then I hope you can take a look.

mgeisler added a commit that referenced this issue Dec 26, 2020
The `wrap_optimal_fit algorithm` computes the penalty for a gap as
`gap * gap`. If a fragment has a size near `usize::max_value()` and if
the line width is small, this computation can easily overflow.

When this happened, we would previously abort or unwind. Now, we
instead do the computations with checked arithmetic and detect the
overflow. We then proceed to wrap the half of the fragments by
themselves. If this work, we then wrap the second half. This way, we
might be able to wrap everything without overflow.

Should there be a single fragment which causes the overflow by itself,
this fragment is put on a line by itself.

When wrapping part of the fragments, we might of course end up with a
partial last line. To fix this, we simply pop this line and re-wrap
the fragments that were put onto this line. This ensures no “seams” in
the wrapping.

Fixes #247.
@mgeisler
Copy link
Owner

The branch is in #259.

mgeisler added a commit that referenced this issue Dec 27, 2020
The `wrap_optimal_fit algorithm` computes the penalty for a gap as
`gap * gap`. If a fragment has a size near `usize::max_value()` and if
the line width is small, this computation can easily overflow.

When this happened, we would previously abort or unwind. Now, we
instead do the computations with checked arithmetic and detect the
overflow. We then proceed to wrap the half of the fragments by
themselves. If this work, we then wrap the second half. This way, we
might be able to wrap everything without overflow.

Should there be a single fragment which causes the overflow by itself,
this fragment is put on a line by itself.

When wrapping part of the fragments, we might of course end up with a
partial last line. To fix this, we simply pop this line and re-wrap
the fragments that were put onto this line. This ensures no “seams” in
the wrapping.

Fixes #247.
mgeisler added a commit that referenced this issue Jan 4, 2021
The `wrap_optimal_fit‘ algorithm computes the penalty for a gap as
`gap * gap`. If a fragment has a size near `usize::max_value()` and if
the line width is large, this computation can easily overflow.

When this happened, we would simply crash. Now, we instead do the
computations with checked arithmetic and detect the overflow. We then
proceed to wrap the first half of the fragments by themselves. If this
work, we then wrap the second half. This way, we might be able to wrap
everything without overflow.

Should there be a single fragment which causes the overflow by itself,
this fragment is put on a line by itself.

When wrapping part of the fragments, we might of course end up with a
partial last line. To fix this, we pop the last line and re-wrap the
fragments that were put onto this line. This helps remove some of the
“seams” that would otherwise occur. However, this is not perfect if
there are many over-sized fragments since they can still cause
half-empty lines to appear in the output.

Fixes #247.
@mgeisler mgeisler added the bug label May 6, 2021
@robinkrahl
Copy link
Contributor Author

Sorry, I’ve been working on other projects and didn’t monitor this issue properly.

Is this actually better for you than a simple panic!("Please don't use words longer than usize/4") at the top of the wrap_optimal_fit function? I'm not 100% sure what the precise bound is there, though, but would that be equally useful to you?

Everything is better than a panic. Even just returning an error would be an improvement, because in that case I can just fall back to first fit, or I can reduce the scaling factor.

@robinkrahl
Copy link
Contributor Author

I’ve run some more experiments and I’m a bit confused now. In your more recent comments, you talk about the word width and issues with overlong fragments. I think the issue that I was running into is an overflow in the penalty calculation for very short lines (here). As you pointed out earlier, this limits the width to approximately the square root of the maximum width.

Anyway, I’ve tested your wrap-optimal-fit-checked branch and it seems to work fine for genpdf! :-)

@mgeisler
Copy link
Owner

Hey @robinkrahl,

Thanks for looking at this again! I guess it's time to add a checked version as in the branch... I was hoping to find some nice way of computing the penalties which could completely circumvent the issue, but so far I haven't found a great solution. #259 was vielleicht the best attempt so far — but it's also pretty complex with it's recursive nature. Since I appreciate simple code, I guess checked arithmetic is best. @MakotoE just put up #392 which (also) implements it, I'll have to dig out the wrap-optimal-fit-checked branch and compare with that.

@robinkrahl
Copy link
Contributor Author

I think #392 tries to fix this using saturating additions, which you investigated here and came to the conclusion that it does not meet the requirements for smawk.

What about #289? Wouldn’t it mostly mitigate the problem for realistic use cases? And it should still be possible to detect the overflow case by checking for infinity.

@mgeisler
Copy link
Owner

What about #289? Wouldn’t it mostly mitigate the problem for realistic use cases? And it should still be possible to detect the overflow case by checking for infinity.

I think it's even better: there are no infinities because the penalties are scaled up to some maximum value: 93e5114

This approach also has a funny side effect of making the penalties dependent on the line length. That is, a gap of 10 is seen as catastrophic if the line length is 20, but seen as negligible if the line length is 1000. That should be a good feature, and if you know the line length, I believe you can compute suitable penalties (now that they're adjustable: #389) to get the effect you want.

However, since this is different that the current approach, I recall spending a lot of time with a Jupyter Notebook trying to figure out if I can scale the penalties in a nice way to make them independent of the line width — but now I think I might have tried to solve a non-problem.

@robinkrahl
Copy link
Contributor Author

Sounds great! Please let me know if you want me to test or review something.

@mgeisler
Copy link
Owner

Sounds great! Please let me know if you want me to test or review something.

Thanks! I don't expect I'll get around to looking at this until the weekend, but I'll let you know!

@NeverGivinUp
Copy link

Playground example for the bug

@NeverGivinUp
Copy link

More weird test results with the playground example:

  1. does not panic in release mode
  2. Creates 4 lines for a factor of 1000.0 in release mode but only 3 for a factor of 100.0

@adetaylor
Copy link

does not panic in release mode

Overflow checks are done as standard only in debug mode: https://doc.rust-lang.org/rustc/codegen-options/index.html#overflow-checks so hopefully that explains your symptoms?

@NeverGivinUp
Copy link

Ah, yes. That's it. In release mode it doesn't perform checks. It just produces false results.

mgeisler added a commit that referenced this issue Jan 2, 2022
The new `wrap_first_fit.rs` and `wrap_optimal_fit.rs` fuzz tests
generate completely random fragments with arbitrary widths. They then
check that the fragments can be wrapped without overflow.

The tests currently fail in less than a second and triggers the
overflows mentioned in #247 and #416.
mgeisler added a commit that referenced this issue Jan 3, 2022
This changes the type used for internal width computations in the wrap
algorithms. Before, we used `usize` to represent the fragment widths
and for the line widths. This could make the optimal-fit wrapping
algorithm overflow when it tries to compute the optimal wrapping cost.
The problem is that the algorithm computes a cost using integer values
formed by

    (line_width - target_width)**2

When `line_width` is near `usize::MAX`, this computation can easily
overflow.

By using an `f64` for the cost computation, we achieve two things:

* A much larger range for the cost computation: `f64::MAX` is about
  1.8e308 whereas `u64::MAX` is only 1.8e19. Computing the cost with a
  fragment width in the range of `u64`, will thus not exceed 3e38,
  something which is easily represented with a `f64`. This means that
  wrapping fragments derived from a `&str` cannot overflow.

  Overflows can still be triggered when fragments with extreme
  proportions are formed directly. The boundary seems to be around
  1e170 with fragment widths above this limit triggering overflows.

* Applications which wrap text using proportional fonts will already
  be operating with widths measured in floating point units. Using
  such units internally makes life easier for such applications, as
  shown by the changes in the Wasm demo.

Fixes #247
Fixes #416
@mgeisler
Copy link
Owner

mgeisler commented Jan 3, 2022

Hi all, I've been testing different ways to fixing this. My favorite is #421, which changes the internal computations to use f64. The extended range of that type prevents overflows when fragments have widths less than about 1e170. In particular, fragments with widths smaller than u64::MAX, will not cause overflows.

I also played with changing the type used for all widths to either u16 or u32 in #420. However, this seems less flexible than the approach in #421.

mgeisler added a commit that referenced this issue Jan 9, 2022
The optimization problem solved by the optimal-fit algorithm is
fundamentally a minimization problem. It is therefore not sensible to
allow negative penalties since all penalties are there to discourage
certain features:

* `nline_penalty` discourages breaks with more lines than necessary,

* `overflow_penalty` discourages lines longer than the line width,

* `short_last_line_penalty` discourages short last lines,

* `hyphen_penalty` discourages hyphenation

Making this change surfaces the overflow bug behind #247 and #416.
This will be fixed next via #421 and this commit can be seen as a way
of simplifying that PR.
mgeisler added a commit that referenced this issue Jan 9, 2022
The optimization problem solved by the optimal-fit algorithm is
fundamentally a minimization problem. It is therefore not sensible to
allow negative penalties since all penalties are there to discourage
certain features:

* `nline_penalty` discourages breaks with more lines than necessary,

* `overflow_penalty` discourages lines longer than the line width,

* `short_last_line_penalty` discourages short last lines,

* `hyphen_penalty` discourages hyphenation

Making this change surfaces the overflow bug behind #247 and #416.
This will be fixed next via #421 and this commit can be seen as a way
of simplifying that PR.
mgeisler added a commit that referenced this issue Jan 9, 2022
The optimization problem solved by the optimal-fit algorithm is
fundamentally a minimization problem. It is therefore not sensible to
allow negative penalties since all penalties are there to discourage
certain features:

* `nline_penalty` discourages breaks with more lines than necessary,

* `overflow_penalty` discourages lines longer than the line width,

* `short_last_line_penalty` discourages short last lines,

* `hyphen_penalty` discourages hyphenation

Making this change surfaces the overflow bug behind #247 and #416.
This will be fixed next via #421 and this commit can be seen as a way
of simplifying that PR.
mgeisler added a commit that referenced this issue Jan 9, 2022
The optimization problem solved by the optimal-fit algorithm is
fundamentally a minimization problem. It is therefore not sensible to
allow negative penalties since all penalties are there to discourage
certain features:

* `nline_penalty` discourages breaks with more lines than necessary,

* `overflow_penalty` discourages lines longer than the line width,

* `short_last_line_penalty` discourages short last lines,

* `hyphen_penalty` discourages hyphenation

Making this change surfaces the overflow bug behind #247 and #416.
This will be fixed next via #421 and this commit can be seen as a way
of simplifying that PR.
mgeisler added a commit that referenced this issue Jan 9, 2022
This changes the type used for internal width computations in the wrap
algorithms. Before, we used `usize` to represent the fragment widths
and for the line widths. This could make the optimal-fit wrapping
algorithm overflow when it tries to compute the optimal wrapping cost.
The problem is that the algorithm computes a cost using integer values
formed by

    (line_width - target_width)**2

When `line_width` is near `usize::MAX`, this computation can easily
overflow.

By using an `f64` for the cost computation, we achieve two things:

* A much larger range for the cost computation: `f64::MAX` is about
  1.8e308 whereas `u64::MAX` is only 1.8e19. Computing the cost with a
  fragment width in the range of `u64`, will thus not exceed 3e38,
  something which is easily represented with a `f64`. This means that
  wrapping fragments derived from a `&str` cannot overflow.

  Overflows can still be triggered when fragments with extreme
  proportions are formed directly. The boundary seems to be around
  1e170 with fragment widths above this limit triggering overflows.

* Applications which wrap text using proportional fonts will already
  be operating with widths measured in floating point units. Using
  such units internally makes life easier for such applications, as
  shown by the changes in the Wasm demo.

Fixes #247
Fixes #416
mgeisler added a commit that referenced this issue Jan 9, 2022
This changes the type used for internal width computations in the wrap
algorithms. Before, we used `usize` to represent the fragment widths
and for the line widths. This could make the optimal-fit wrapping
algorithm overflow when it tries to compute the optimal wrapping cost.
The problem is that the algorithm computes a cost using integer values
formed by

    (line_width - target_width)**2

When `line_width` is near `usize::MAX`, this computation can easily
overflow.

By using an `f64` for the cost computation, we achieve two things:

* A much larger range for the cost computation: `f64::MAX` is about
  1.8e308 whereas `u64::MAX` is only 1.8e19. Computing the cost with a
  fragment width in the range of `u64`, will thus not exceed 3e38,
  something which is easily represented with a `f64`. This means that
  wrapping fragments derived from a `&str` cannot overflow.

  Overflows can still be triggered when fragments with extreme
  proportions are formed directly. The boundary seems to be around
  1e170 with fragment widths above this limit triggering overflows.

* Applications which wrap text using proportional fonts will already
  be operating with widths measured in floating point units. Using
  such units internally makes life easier for such applications, as
  shown by the changes in the Wasm demo.

Fixes #247
Fixes #416
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
4 participants