-
Notifications
You must be signed in to change notification settings - Fork 126
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
fix Series.from_list/2 bugs #826
Conversation
https://github.com/elixir-explorer/explorer/actions/runs/7566406181/job/20603730541?pr=826 |
In my opinion, we should continue inferring those cases as s64 and continue raising if we overflow or underflow the dtype. The improvement we could do is to improve the error message. The conversion to u64, s64, etc happens inside Rustler and I am not sure if Rustler let's us customize the error message. @philss, do you know if we can customize the "argument error" bits here?
In case we cannot, I would wrap "Explorer.PolarsBackend.Shared.from_list" into a |
Btw, |
https://hexdocs.pm/explorer/Explorer.Series.html#from_list/2
I have established the base line of what is not working in https://github.com/elixir-explorer/explorer/actions/runs/7570277474/job/20615312348
In this PR, I have attempted to address the below:
Please review - I am pretty sure there is room for improvement . Also take a stab at it improving the PR. I can learn more if you can attempt to improvise thing by seeing how others do it in a better way. I have tested these scenarios in Polars and it handles well - either it throws descriptive error, or creates series as expected. |
As per above, I don’t think we should infer u64. Operations such as subtract can be confusing, especially coming from Elixir, and it should always be opt-in. For the cases listed here, we should improve the error message. I would not change the inference code. |
I also think doing additional traversals on the list will be expensive. Hence my suggestion of wrapping around the calls to Rust. :) |
@lkarthee I had the same temptation but I think @josevalim is right: we should keep the inference code as is. We could follow the pattern you've established where we infer the "minimal" type that can represent the integers given to Also I got the following in Polars: let s1 = Series::new("s1", [1, 2]);
// shape: (2,)
// Series: 's1' [i32]
// [
// 1
// 2
// ] So it looks like they too infer signed integers by default (their |
@josevalim no, I don't think we can. The only way I know to personalize this message is if we implement the interactor over explorer/native/explorer/src/series.rs Line 53 in 1e655eb
It's a more verbose version, but it's doable. And just to add my 2 cents: I'm also in the team of "not try to infer". I believe that function is already too complex, and users can always pass the desired |
Multiple parts to the story:
All code snippets are using explorer/main SHA - 62e7716
|
@lkarthee I'm totally with you when it comes to docs, error messages, and tests. The only part of this that's tripping me up is the change to the inference code. It's not about overhead, but ergonomics. The stance we're taking is that For the specifics:
|
Thank you @billylanchantin sharing your view and for reading such a long post - I made a mistake of pushing tests before writing cover. Perhaps that caused confusion. Even this PR is complex. I tend not mix multiple things, but I could not scope it any lesser.
Just to be clear - We are still defaulting to {:s, 64} unless we have a {:u, 64} value in list. We are not going into subtypes of
Docs - https://hexdocs.pm/explorer/Explorer.Series.html#module-creating-series
Option :dtype documentation.
There is no word of defaulting to s64. It says it will infer - gives example of integers, floats, etc. At the bottom of section it says:
The precedent here is error w.r.to incompatible values not silent cast to nil. Integer split to subtypes is not yet released (if I am not wrong). We can look at Py Polars behaviour. pl.Series([-1,1], dtype=pl.UInt8)
# throws incompatible types error. I had personally faced a scenario where dates were not parsed - #566 (comment) . It's very frustrating when things fail silently. If data is wrong or I am using wrong type, I want it to fail with a big error. Else what is the point having types? |
Since there are many bugs here, we should probably try to fix them individually instead of all at once. As a starter, I would look into:
The above should raise. It doesn't need to raise something good for now but it should raise.
This should not raise.
Yeah, that's because s64 is new. We should say a list of integers are always inferred as s64. A list of floats (and integers) is always f64 by default. |
I have pushed a fix for this. Now it works accordingly. :) |
This reverts commit 9b52f0a.
Fixed in main too. |
I have changed the pr to add
Just to be clear - earlier implementation is scrapped. There is no merge logic. Iterate list only once which was already being done in Currently overflow and underflow can occur. Series.from_list([0, 256], dtype: {:u, 8})
#Explorer.Series<
Polars[2]
u8 [0, 0]
>
Benchmark script & resultsdatetime = DateTime.utc_now()
seed = DateTime.to_unix(datetime)
key = Nx.Random.key(seed)
{randints, _new_key} = Nx.Random.randint(key, -2**63, 2**63-1, shape: {100, 1000}, type: :s64)
list_1k = Nx.to_list(randints)
Benchee.run(
%{
"1_b_100x1000" => fn -> Enum.map(list_1k, fn x -> Explorer.Series.from_list(x) end) end,
"3_d_100x1000_s64" => fn -> Enum.map(list_1k, fn x -> Explorer.Series.from_list(x, dtype: {:s, 64}) end) end,
"2_b_100x1000_main" => fn -> Enum.map(list_1k, fn x -> Explorer.Series.from_list(x, old: true) end) end,
"4_d_100x1000_s64_main" => fn -> Enum.map(list_1k, fn x -> Explorer.Series.from_list(x, dtype: {:s, 64}, old: true) end) end,
"5_b_100x1000_strict" => fn -> Enum.map(list_1k, fn x -> Explorer.Series.from_list(x, strict: true) end) end,
"6_d_100x1000_s64_strict" => fn -> Enum.map(list_1k, fn x -> Explorer.Series.from_list(x, dtype: {:s, 64}, strict: true) end) end,
}
) Here are the results. Don't see any abnormal increase in time.
|
Feel free to close this PR, if you feel we don't need strict flag. |
end | ||
|
||
defp infer_type(item, _preferred, _strict) when is_float(item) or item in @non_finite, | ||
do: {:f, 64} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Btw, we also have precision issues with floats, since f32 cannot represent as large numbers as f64. :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But that should probably be another pull request.
@@ -251,6 +251,7 @@ defmodule Explorer.Series do | |||
* `:dtype` - Cast the series to a given `:dtype`. By default this is `nil`, which means | |||
that Explorer will infer the type from the values in the list. | |||
See the module docs for the list of valid dtypes and aliases. | |||
* `:strict` - when `true` raises on overflow and underflow - defaults to `false`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When it is false, what happens? The value is simply discarded?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am a bit confused because it seems Rustler will raise on overflow-underflow if strict: false
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When false overflow/underflow can happen. Discard only happens when a series is created as {:s, 64} and cast it to {:s, 8}. And cast does not happen with existing code with and without strict.
Series.from_list([-129, 256], dtype: {:s, 16}) |> Series.cast({:s, 8})
#Explorer.Series<
Polars[2]
s8 [nil, nil]
>
More details about rustler behaviour - #826 (comment)
Hi @lkarthee, I have dropped some comments, but I am still not ahead we should go with this. My understanding is that Rustler will raise anyway, so will |
I was confused about rustler's behaviour - figured out that iex(2)> Series.from_list([-129, 256], dtype: {:s, 8})
19:49:17.265 [info] dtype - {:s, 8} # <- Just to make sure we are passing before rustler is called
type_name of val is alloc::vec::Vec<core::option::Option<i8>> # <- log from rust that it is using i8
#Explorer.Series<
Polars[2]
s8 [127, 0]
>
iex(3)> Series.from_list([256], dtype: {:u, 8})
#Explorer.Series<
Polars[1]
u8 [0]
>
iex(4)> Series.from_list([-129, 256], dtype: {:u, 8})
** (ArgumentError) argument error
(explorer 0.9.0-dev) Explorer.PolarsBackend.Native.s_from_list_u8("", [-129, 256])
(explorer 0.9.0-dev) lib/explorer/polars_backend/series.ex:24: Explorer.PolarsBackend.Series.from_list/2
(explorer 0.9.0-dev) lib/explorer/series.ex:417: Explorer.Series.from_list/2 Overflow of float 32 - results in crash of VM: iex(3)> Series.from_list([3.402823E+39])
#Explorer.Series<
Polars[1]
f64 [3.402823e39]
>
iex(4)> Series.from_list([3.402823E+39], dtype: {:f, 32})
thread '<unnamed>' panicked at src/series.rs:86:1:
called `Result::unwrap()` on an `Err` value: {error, badarg}
stack backtrace:iex(4)> Series.from_list([3.402823E+39], dtype: {:f, 32})
libunwind: stepWithCompactEncoding - invalid compact unwind encoding
Abort trap: 6 |
Right, that’s why I think this pull request is a bit too early. We need to make sure that the default behavior can be not strict, nor crash the Rust side, instead we need to propagate the error correctly. From the looks of it, strict: true may be the safest choice, specially as it happens by default. so my suggestion is to improve the messages and the crashes before we add any new code or large change. What is the minimum work we can do to provide a good user experience with the current behavior? |
Alright lets into look into f32 crashes and overflow/underflow first before proceeding any further. We can figure out rest as we go. f32 crash happening due to below code ? impl<'a> Decoder<'a> for f32 {
fn decode(term: Term) -> NifResult<f32> {
let res: f64 = term.decode()?;
let res = res as f32;
// Values bigger than f32 are coerced as infinity
if res.is_finite() {
Ok(res)
} else {
Err(Error::BadArg)
}
}
} |
float 32 crash is happening due to Also what should we do on overflow or underflow ? I just returned None on Err(). This solves the crash - but overflow I don't know how to handle it? Any suggestions @philss @josevalim @billylanchantin macro_rules! from_list_float {
($name:ident, $type:ty, $module:ident) => {
#[rustler::nif(schedule = "DirtyCpu")]
pub fn $name(name: &str, val: Term) -> ExSeries {
let nan = atoms::nan();
let infinity = atoms::infinity();
let neg_infinity = atoms::neg_infinity();
ExSeries::new(Series::new(
name,
val.decode::<ListIterator>()
.unwrap()
.map(|item| {
match item.get_type() {
//TermType::Number => Some(item.decode::<$type>().unwrap()),
TermType::Number => match item.decode::<$type>() {Ok(f) => Some(f), Err(_) => None},
TermType::Atom => {
if nan.eq(&item) {
Some($module::NAN)
} else if infinity.eq(&item) {
Some($module::INFINITY)
} else if neg_infinity.eq(&item) {
Some($module::NEG_INFINITY)
} else {
None
}
}
term_type => panic!("from_list/2 not implemented for {term_type:?}"),
}})
.collect::<Vec<Option<$type>>>(),
))
}
};
} |
I think crashing is fine, but we need to crash exactly in the same way as the other ones, so we can rescue it accordingly in the elixir layer |
#837 handles float issue. What to do with integer overflow ? https://doc.rust-lang.org/book/ch03-02-data-types.html#integer-overflow
|
This is exactly how Nx behaves. So I am glad to call it a day. |
To be clear, Nx doesn't deal with overflows/underflows, they always happen across all operations, and I'd say it is fine to have the same behaviour here too. One of the main points of those libraries is to enable high performance, so the trade-off makes sense? |
Should I
|
Read this comment for information. #826 (comment)
Added some tests as i could not create a series with u64 value