Stream: compiler development

Topic: Dec to/from attos


view this post on Zulip Eric Rogstad (Aug 03 2026 at 15:56):

I've just created a PR that adds Dec.from_attos and Dec.to_attos: https://github.com/roc-lang/roc/pull/10573. Making a thread here since it's a change to the public API that perhaps warrants discussion.

As implemented, Dec is a wrapper for an I128 that represents the Dec's value scaled by a factor of 10^18. For example, if the value in the Dec is 0.0123, that would be represented as the I128 12300000000000000 (1.23 * 10^16).

In some contexts (such as JSON parsing), it would be convenient to be able to construct a Dec by first constructing this I128 value, and then converting it to a Dec. In that case the conversion is trivial, because the bits are already in the shape we want. That I128 value is literally how the Dec is stored.

At present though, there's no way to directly convert such an I128 to a Dec. Note for example that I128.to_dec_try isn't what we want, because that converts an un-scaled integer to a Dec, and the value of the I128 might be higher than Dec.highest (which is why it returns a Try). You also couldn't just divide your I128 by 10^18 and then convert it to a Dec and then multiply it back by 10^18, because you'd lose precision on the integer division.

So, to give developers the option of building the I128 first and then converting it to a Dec, I propose to add Dec.from_attos and Dec.to_attos. Note that "atto" is the SI prefix for 10^-18. So the idea is that the I128 already just directly holds the value of the number of "attos" in the value that a Dec represents. And this just gives developers the option of working with that integer number of attos.

view this post on Zulip Eric Rogstad (Aug 03 2026 at 15:57):

To go into more detail about why you'd want to build the I128 first and then convert it to a Dec, rather than just operating on a Dec the whole time, there are two primary reasons I'm aware of:

  1. If you're going to be doing something like parsing digits where you'll be multiplying an accumulated value by 10 and then adding to it in a loop, if you're operating on Decs then each of those * 10 operations is a mul_and_decimalize that has to do division internally (because it's multiplying two scaled-by-10^18 values, giving you a scaled-by-10^36 value, which you then need to divide back by 10^18 to get the right scale). Whereas if you had been operating on an I128 that you convert to Dec once at the end, there's no division needed.
  2. You may have just received your value as an I128 in the first place. For example, if working with cryptocurrency, Ethereum stores its value as an integer number of wei which are defined as a 10^-18th of an Ether. So if you received an integer number of wei, you could convert that to a Dec amount of Ether by interpreting that integer value directly as the internal I128 of the Dec, with no math needed.

view this post on Zulip Richard Feldman (Aug 03 2026 at 16:06):

pretty clever! I don't think the name attos is very discoverable, although it does make great sense when you explain it. That said, I can't think of a better name. :sweat_smile:

view this post on Zulip Richard Feldman (Aug 03 2026 at 16:08):

like to_underlying_i128 or something could arguably be more self-descriptive, but then we're kinda committing to this exact representation forever. Not that I'd expect it to ever change, but it is nice that if you want the attos and the underlying representation changes, we can presumably make that a nonbreaking change that affects perf but not correctness

view this post on Zulip Eric Rogstad (Aug 03 2026 at 16:10):

I don't think the name attos is very discoverable

Yeah, my Claude first proposed Dec.from_scaled, but I liked from_attos better because it just more directly tells you exactly what's going on (you don't have to go look up what the scale factor is; though of course you may have to look up what an "atto" is :stuck_out_tongue:).

Another option we considered was from_bits, matching the float conversions, but I didn't like that either because you're not passing in an opaque set of bits, it's an I128 that has a clear semantic meaning.

view this post on Zulip Eric Rogstad (Aug 03 2026 at 16:14):

Good point about being able to change the representation w/o having to change the function name. Hadn't thought of that.

view this post on Zulip Eric Rogstad (Aug 03 2026 at 16:19):

One thing I was wondering is if there's a way to make it more discoverable at least in the docs via a pun reference. Like I thought I remembered that there was an idea to put an entry for something like List.reduce in there that just says "see: List.fold" or something like that. So we could do the same thing with Dec.to_scaled or whatever. Though honestly I'm not sure what the more discoverable name would be.

I hadn't thought of that to_underlying_i128 option. Maybe that's the clearest, but as you say it has that committing-to-the-representation problem. Though I guess if we just put that in the docs and not the code it's okay?

view this post on Zulip Eric Rogstad (Aug 03 2026 at 16:21):

Oh, for discoverability my Claude suggests you could change the doc comment on I128.to_dec_try to point out the alternative:

## Convert an [I128] to a [Dec], returning `Err(OutOfRange)` if the
## integer value does not fit in [Dec]'s fixed-point range. This reads the
## integer as a count of ones; to read one as a count of attos (10^-18),
## see [Dec.from_attos].

I might tweak the wording that it came up with, but the idea seems not crazy.

view this post on Zulip Richard Feldman (Aug 03 2026 at 16:28):

that seems reasonable! :thumbs_up:

view this post on Zulip Eric Rogstad (Aug 03 2026 at 17:25):

Updated the PR with doc comments linking to the _attos conversions from I128.to_dec_try, Dec.to_i128_wrap, and Dec.to_i128_try.

view this post on Zulip Eric Rogstad (Aug 03 2026 at 17:26):

Though when looking at those functions I noticed that Dec.to_i128_wrap and Dec.to_i128_try are kind of weird. It looks like we have both just to match the pattern of the other Dec-to-int conversions, which could overflow since the integer part of a Dec is potentially larger than e.g. I64. But the integer part of a Dec is smaller than I128, so "wrapping" doesn't mean anything here, and the _try version always returns an Ok.

So should we just replace Dec.to_i128_wrap and Dec.to_i128_try with a single Dec.to_i128 whose signature is Dec -> I128 with no Try wrapper?

view this post on Zulip Richard Feldman (Aug 03 2026 at 17:37):

yeah that sounds better :+1:

view this post on Zulip Eric Rogstad (Aug 07 2026 at 10:01):

Okay, so I went to implement that change to collapse Dec.to_i128_wrap and Dec.to_i128_try into a single Dec.to_i128, and came across a number of bugs with the Dec-to-int conversions in the existing code.

Here's a PR to fix them: https://github.com/roc-lang/roc/pull/10664

(Planning to implement the collapse in a follow-up PR. I've got a draft, but wanted to get these bug fixes in first.)

view this post on Zulip Eric Rogstad (Aug 09 2026 at 16:48):

Oh, looks like you might have separately fixed these bugs via https://github.com/roc-lang/roc/pull/10681

Going through my PR to see if any of my changes are still worth keeping...

view this post on Zulip Eric Rogstad (Aug 11 2026 at 18:43):

Extracted this PR out of my now redundant #10664. Adds some tests, makes the LLVM backend fail the build on unhandled ops rather than crashing at runtime, and adds a compile-time check for little-endianness in the conversion wrappers.

https://github.com/roc-lang/roc/pull/10734

view this post on Zulip Eric Rogstad (Aug 11 2026 at 23:10):

And then I have two draft PRs building on this work:

  1. https://github.com/roc-lang/roc/pull/10737 implements the replacement of Dec.to_i128_wrap and Dec.to_i128_try with Dec.to_i128, as we discussed above.
  2. https://github.com/roc-lang/roc/pull/10736 implements the llvm-conversion-op-explicit-dispatch project.

Last updated: Aug 12 2026 at 12:35 UTC