[m-rev.] for review: speed up string to int and string to uint conversion

Zoltan Somogyi zoltan.somogyi at runbox.com
Sun Aug 9 20:08:35 AEST 2026



On Sun, 9 Aug 2026 13:58:31 +1000, Julien Fischer <jfischer at opturion.com> wrote:

> Some smaller inefficiencies are present as well, such as computing the length
> of the input string twice instead of just once, or using a checked int to uint
> conversion where a cast would be safe.
> 
> This diff addresses issue (1) by avoiding the use of higher-order code
> completely. This is much faster and not any more verbose than the existing
> code (which was quite verbose for reasons of its own).
> 
> Issue (2) is resolved using a technique from strtol() implementations in
> various C libraries.

I would reword this in the active voice.

> It arranges the overflow test in such a way that the
> division is invariant and can be hoisted out of the loop.

LOOP invariant.

> 
> I benchmarked string.to_int/2 using the benchmarking module on one million
> randomly generated strings of between 1 and 31 digits. At the default
> optimization level in asm_fast.gc on my machine, the results are as follows:
> 
>     rotd-2026-05-03: 3170 ms (higher-order, old broken overflow check)
>     rotd-2026-08-04: 4150 ms (higher-order, one div per loop overflow check)
>     this diff: 1740 ms
> 
> (This is far from a systematic benchmark, but the results speak for
> themselves.)

Yes, they do!

>      % preceded by a plus or minus sign. For bases > 10, digits 10 to 35
>      % are represented by the letters A-Z or a-z. If the string does not match
>      % this syntax or the number is not in the range [min_int, max_int],
> -    % the predicate fails.
> +    % the predicate fails. An exception is thrown if the specified base is
> +    % not in [2, 36].

Throws an exception if ...

>  :- pred base_string_to_int(int::in, string::in, int::out) is semidet.
> 
>      % Convert a signed base N string to an int. Throws an exception
>      % if the string argument is not precisely an optional sign followed by
>      % a non-empty string of base N digits, or if the number is not in
> -    % the range [min_int, max_int].
> +    % the range [min_int, max_int], or if the specified base is not in [2, 36].
>      %
>  :- func det_base_string_to_int(int, string) = int.

Put a line break before "Throws", and start each condition on a new line
with a minus sign, as if in an itemize scope.

> @@ -1597,13 +1598,15 @@
>      % must contain one or more digits in the specified base. For bases > 10,
>      % digits 10 to 35 are represented by the letters A-Z or a-z. If the string
>      % does not match this syntax or the number is not in the range
> -    % [0, max_uint], the predicate fails.
> +    % [0, max_uint], the predicate fails. An exception is thrown if the
> +    % specified base is not in [2, 36].
>      %
>  :- pred base_string_to_uint(int::in, string::in, uint::out) is semidet.

Throws an exception if ...

>      % Convert an unsigned base N string to a uint. Throws an exception
>      % if the string argument is not precisely a non-empty string of base N
> -    % digits, or if the number is not in the range [0, max_uint].
> +    % digits, or if the number is not in the range [0, max_uint], or if
> +    % the specified base is not in [2, 36].
>      %
>  :- func det_base_string_to_uint(int, string) = uint.

Again, use itemize-like layout.

> +do_base_string_to_int(Base, String, Int) :-
> +    string.index(String, 0, Char),
> +    End = string.count_code_units(String),
> +    ( if
> +        ( Char = ('-'), Sign0 = negative
> +        ; Char = ('+'), Sign0 = positive
> +        )
> +    then
> +        Sign = Sign0,
> +        % Start at the first digit, which *should* be just after the sign.
> +        End > 1,
> +        Start = 1
> +    else
> +        Sign = positive,
> +        Start = 0
> +    ),
> +    % The divisions below are all safe since our callers set Base
> +    % to be in 2..36.
> +    (
> +        Sign = positive,
> +        CutOff = max_int `unchecked_quotient` Base,
> +        CutLimit = max_int `unchecked_rem` Base,

If you are looking for further speedups, this could be done here.
Realistically, there are two possible values of max_int, and therefore
we could do a lookup in one of two 36-element precomputed arrays,
at least when targeting C. (Arranging access to such an array may be
slower than two divisions in Java/C#.)

> +    % Convert the base Base digits of String between I and End into an int,
> +    % accumulating the result in !Int. Fail if the value being accumulated
> +    % would exceed max_int.

To make that sentence make sense, you need to duplicate the clause head
at the start of the comment. Without it, the reader does not know what I is.
In any case, I would rename it as "CurOffset", and rename J as "NextOffset".
Likewise, End->EndOffset.

> +    % We must detect the overflow *before* it happens. Computing
> +    % (Base * !.Int) + M and then testing the result does not work,

... or !.Int.

> +    % because the multiplication may overflow by more than the range of
> +    % an int. In that case the wrapped-around result is again greater

Add comma after "case".

> +    % than !.Int, and so is indistinguishable from a result that did not
> +    % overflow.
> +    %
> +    % Requiring that !.Int =< (max_int - M) // Base be true at each iteration
> +    % of the loop does detect overflow, but at the cost of having a division in
> +    % each iteration.
> +    %
> +    % Instead, we can hoist the division out of the loop body observing that
> +    % the above check depends on the digit M only through a comparison that
> +    % can be split into cases. Specifically, we can write max_int as:
> +    %
> +    %   max_int = (Base * CutOff) + CutLimit
> +    %
> +    % CutOff and CutLimit are invariant and our caller can compute them as:

*will* compute them as

> +    %   CutOff   = max_int // Base
> +    %   CutLimit = max_int rem Base  (0 =< CutLimit < Base)

I would add "implying" before "0".

> +    % Given these, (Base * !.Int) + M does not exceed max_int if and only
> +    % if either:

To make this make sense, you have to first tell the reader what M is,
and that it is less than Base.

> +    % - !.Int < CutOff, in which case, since M < Base,
> +    %
> +    %       (Base * !.Int) + M =< (Base * (CutOff - 1)) + (Base - 1)
> +    %                           = (Base * CutOff) - 1
> +    %                          =< max_int
> +    %

At first, I didn't see where the first line's inequality comes from.
Say explicitly it comes from !.Int < CutOff. or just replace "since M < Base"
with "therefore".

> +    %   whatever the digit is; or

the digit M is

> +    % - !.Int = CutOff and M =< CutLimit, in which case
> +    %
> +    %       (Base * !.Int) + M =< (Base * CutOff) + CutLimit = max_int.
> +    %
> +    % If !.Int > CutOff, then (Base * !.Int) >= (Base * CutOff) + Base,
> +    % which exceeds max_int whatever the digit is.

Add a line break and an indent after "then", to make this case look like
the first two.

I don't think CutOff and CutLimit are good names for these roles,
because in a sense they are BOTH limits.

A good name for CutOff would tell readers that this is the maximum
that !.Int can be before the addition of a digit, and the CutLimit
is the max value of that digit *if* !.Int = CutOff. However, right now
I also cannot think of any *short* names that do that. Maybe
something like MaxSoFar/MaxBeforeNextDigit and MaxNextDigit?

> +    % As for do_base_string_to_positive_int_loop above, but accumulate a
> +    % negative value, and fail if it would be less than min_int.
> +    % Here our caller gives us

s/As for/This predicate is similar to/, and then adjust the verbs.
And the comments above apply here as well.

> +do_base_string_to_uint(Base, String, UInt) :-
> +    End = string.count_code_units(String),
> +    End > 0, % Fail if we have the empty string.
> +    UBase = uint.cast_from_int(Base),
> +    % Both of these divisions are safe since our callers set Base
> +    % to be in 2..36.
> +    CutOff = max_uint `unchecked_quotient` UBase,
> +    CutLimit = max_uint `unchecked_rem` UBase,
> +    do_base_string_to_uint_loop(UBase, Base, CutOff, CutLimit, String,
> +        0, End, 0u, UInt).

And here.

> +    % The overflow check here is the one described in the comment on
> +    % do_base_string_to_positive_int_loop above.
> +    %
> +:- pred do_base_string_to_uint_loop(uint::in, int::in, uint::in, uint::in,
> +    string::in, int::in, int::in, uint::in, uint::out) is semidet.
> +
> +do_base_string_to_uint_loop(UBase, Base, CutOff, CutLimit, String,
> +        I, End, !UInt) :-
> +    ( if I < End then
> +        unsafe_index_next(String, I, J, Char),
> +        char.unsafe_base_digit_to_int(Base, Char, M),
> +        MU = uint.cast_from_int(M),

I don't think this cast is a builtin. You can eliminate its runtime cost
by adding a uint version of unsafe_base_digit_to_int. Since bases are
inherently positive, arguably *that* should be the main version anyway.

Despite the quantity of comments above, an excellent diff!

Zoltan.


More information about the reviews mailing list