Skip to content

Conversation

@rvanasa
Copy link
Contributor

@rvanasa rvanasa commented Aug 12, 2025

Reintroduces similar functionality to Buffer.binarySearch() based on feedback.

@rvanasa rvanasa requested a review from a team as a code owner August 12, 2025 20:23
@github-actions
Copy link

github-actions bot commented Aug 12, 2025

No description provided.

@github-actions
Copy link

github-actions bot commented Aug 12, 2025

Benchmark Results

bench/ArrayBuilding.bench.mo $({\color{gray}0\%})$

Large known-size array building

Compares performance of different data structures for building arrays of known size.

Instructions: ${\color{gray}0\%}$
Heap: ${\color{gray}0\%}$
Stable Memory: ${\color{gray}0\%}$
Garbage Collection: ${\color{gray}0\%}$

Instructions

1000 100000 1000000
List 548_233 $({\color{gray}0\%})$ 48_324_535 $({\color{gray}0\%})$ 478_161_875 $({\color{gray}0\%})$
Buffer 342_005 $({\color{gray}0\%})$ 33_903_435 $({\color{gray}0\%})$ 339_003_650 $({\color{gray}0\%})$
pure/List 302_135 $({\color{gray}0\%})$ 30_003_590 $({\color{gray}0\%})$ 300_055_972 $({\color{gray}0\%})$
VarArray ?T 180_526 $({\color{gray}0\%})$ 17_802_956 $({\color{gray}0\%})$ 178_003_171 $({\color{gray}0\%})$
VarArray T 160_813 $({\color{gray}0\%})$ 15_803_243 $({\color{gray}0\%})$ 158_003_458 $({\color{gray}0\%})$
Array (baseline) 42_695 $({\color{gray}0\%})$ 4_003_125 $({\color{gray}0\%})$ 40_003_340 $({\color{gray}0\%})$

Heap

1000 100000 1000000
List 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$
Buffer 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$
pure/List 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$
VarArray ?T 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$
VarArray T 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$
Array (baseline) 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$

Garbage Collection

1000 100000 1000000
List 10.05 KiB $({\color{gray}0\%})$ 797.56 KiB $({\color{gray}0\%})$ 7.67 MiB $({\color{gray}0\%})$
Buffer 8.71 KiB $({\color{gray}0\%})$ 782.15 KiB $({\color{gray}0\%})$ 7.63 MiB $({\color{gray}0\%})$
pure/List 19.95 KiB $({\color{gray}0\%})$ 1.91 MiB $({\color{gray}0\%})$ 19.07 MiB $({\color{gray}0\%})$
VarArray ?T 8.24 KiB $({\color{gray}0\%})$ 781.68 KiB $({\color{gray}0\%})$ 7.63 MiB $({\color{gray}0\%})$
VarArray T 8.23 KiB $({\color{gray}0\%})$ 781.67 KiB $({\color{gray}0\%})$ 7.63 MiB $({\color{gray}0\%})$
Array (baseline) 4.3 KiB $({\color{gray}0\%})$ 391.02 KiB $({\color{gray}0\%})$ 3.82 MiB $({\color{gray}0\%})$
bench/FromIters.bench.mo $({\color{gray}0\%})$

Benchmarking the fromIter functions

Columns describe the number of elements in the input iter.

Instructions: ${\color{gray}0\%}$
Heap: ${\color{gray}0\%}$
Stable Memory: ${\color{gray}0\%}$
Garbage Collection: ${\color{gray}0\%}$

Instructions

100 10_000 100_000
Array.fromIter 48_764 $({\color{gray}0\%})$ 4_712_025 $({\color{gray}0\%})$ 47_103_135 $({\color{gray}0\%})$
List.fromIter 31_698 $({\color{gray}0\%})$ 3_061_541 $({\color{gray}0\%})$ 30_603_553 $({\color{gray}0\%})$
List.fromIter . Iter.reverse 50_297 $({\color{gray}0\%})$ 4_832_563 $({\color{gray}0\%})$ 48_305_477 $({\color{gray}0\%})$

Heap

100 10_000 100_000
Array.fromIter 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$
List.fromIter 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$
List.fromIter . Iter.reverse 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$

Garbage Collection

100 10_000 100_000
Array.fromIter 2.76 KiB $({\color{gray}0\%})$ 234.79 KiB $({\color{gray}0\%})$ 2.29 MiB $({\color{gray}0\%})$
List.fromIter 3.51 KiB $({\color{gray}0\%})$ 312.88 KiB $({\color{gray}0\%})$ 3.05 MiB $({\color{gray}0\%})$
List.fromIter . Iter.reverse 5.11 KiB $({\color{gray}0\%})$ 469.17 KiB $({\color{gray}0\%})$ 4.58 MiB $({\color{gray}0\%})$
bench/ListBufferNewArray.bench.mo $({\color{gray}0\%})$

List vs. Buffer for creating known-size arrays

Performance comparison between List and Buffer for creating a new array.

Instructions: ${\color{gray}0\%}$
Heap: ${\color{gray}0\%}$
Stable Memory: ${\color{gray}0\%}$
Garbage Collection: ${\color{gray}0\%}$

Instructions

0 (baseline) 1 5 10 100 (for loop)
List 1_547 $({\color{gray}0\%})$ 2_916 $({\color{gray}0\%})$ 9_046 $({\color{gray}0\%})$ 13_948 $({\color{gray}0\%})$ 74_564 $({\color{gray}0\%})$
pure/List 1_247 $({\color{gray}0\%})$ 1_355 $({\color{gray}0\%})$ 2_439 $({\color{gray}0\%})$ 3_801 $({\color{gray}0\%})$ 31_868 $({\color{gray}0\%})$
Buffer 2_119 $({\color{gray}0\%})$ 2_271 $({\color{gray}0\%})$ 3_518 $({\color{gray}0\%})$ 5_085 $({\color{gray}0\%})$ 36_640 $({\color{gray}0\%})$

Heap

0 (baseline) 1 5 10 100 (for loop)
List 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$
pure/List 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$
Buffer 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$ 272 B $({\color{gray}0\%})$

Garbage Collection

0 (baseline) 1 5 10 100 (for loop)
List 576 B $({\color{gray}0\%})$ 616 B $({\color{gray}0\%})$ 776 B $({\color{gray}0\%})$ 884 B $({\color{gray}0\%})$ 1.93 KiB $({\color{gray}0\%})$
pure/List 360 B $({\color{gray}0\%})$ 380 B $({\color{gray}0\%})$ 460 B $({\color{gray}0\%})$ 560 B $({\color{gray}0\%})$ 2.3 KiB $({\color{gray}0\%})$
Buffer 856 B $({\color{gray}0\%})$ 864 B $({\color{gray}0\%})$ 896 B $({\color{gray}0\%})$ 936 B $({\color{gray}0\%})$ 1.62 KiB $({\color{gray}0\%})$
bench/PureListStackSafety.bench.mo $({\color{gray}0\%})$

List Stack safety

Check stack-safety of the following pure/List-related functions.

Instructions: ${\color{gray}0\%}$
Heap: ${\color{gray}0\%}$
Stable Memory: ${\color{gray}0\%}$
Garbage Collection: ${\color{gray}0\%}$

Instructions

pure/List.split 24_602_524 $({\color{gray}0\%})$
pure/List.all 7_901_014 $({\color{gray}0\%})$
pure/List.any 8_001_390 $({\color{gray}0\%})$
pure/List.map 23_103_767 $({\color{gray}0\%})$
pure/List.filter 21_104_188 $({\color{gray}0\%})$
pure/List.filterMap 27_404_742 $({\color{gray}0\%})$
pure/List.partition 21_304_994 $({\color{gray}0\%})$
pure/List.join 33_105_326 $({\color{gray}0\%})$
pure/List.flatten 24_805_667 $({\color{gray}0\%})$
pure/List.take 24_605_664 $({\color{gray}0\%})$
pure/List.drop 9_904_119 $({\color{gray}0\%})$
pure/List.foldRight 19_105_768 $({\color{gray}0\%})$
pure/List.merge 31_808_584 $({\color{gray}0\%})$
pure/List.chunks 51_510_344 $({\color{gray}0\%})$
pure/Queue 142_662_505 $({\color{gray}0\%})$

Heap

pure/List.split 272 B $({\color{gray}0\%})$
pure/List.all 272 B $({\color{gray}0\%})$
pure/List.any 272 B $({\color{gray}0\%})$
pure/List.map 272 B $({\color{gray}0\%})$
pure/List.filter 272 B $({\color{gray}0\%})$
pure/List.filterMap 272 B $({\color{gray}0\%})$
pure/List.partition 272 B $({\color{gray}0\%})$
pure/List.join 272 B $({\color{gray}0\%})$
pure/List.flatten 272 B $({\color{gray}0\%})$
pure/List.take 272 B $({\color{gray}0\%})$
pure/List.drop 272 B $({\color{gray}0\%})$
pure/List.foldRight 272 B $({\color{gray}0\%})$
pure/List.merge 272 B $({\color{gray}0\%})$
pure/List.chunks 272 B $({\color{gray}0\%})$
pure/Queue 272 B $({\color{gray}0\%})$

Garbage Collection

pure/List.split 3.05 MiB $({\color{gray}0\%})$
pure/List.all 328 B $({\color{gray}0\%})$
pure/List.any 328 B $({\color{gray}0\%})$
pure/List.map 3.05 MiB $({\color{gray}0\%})$
pure/List.filter 3.05 MiB $({\color{gray}0\%})$
pure/List.filterMap 3.05 MiB $({\color{gray}0\%})$
pure/List.partition 3.05 MiB $({\color{gray}0\%})$
pure/List.join 3.05 MiB $({\color{gray}0\%})$
pure/List.flatten 3.05 MiB $({\color{gray}0\%})$
pure/List.take 3.05 MiB $({\color{gray}0\%})$
pure/List.drop 328 B $({\color{gray}0\%})$
pure/List.foldRight 1.53 MiB $({\color{gray}0\%})$
pure/List.merge 4.58 MiB $({\color{gray}0\%})$
pure/List.chunks 7.63 MiB $({\color{gray}0\%})$
pure/Queue 18.31 MiB $({\color{gray}0\%})$
bench/Queues.bench.mo $({\color{gray}0\%})$

Different queue implementations

Compare the performance of the following queue implementations:

  • pure/Queue: The default immutable double-ended queue implementation.
    • Pros: Good amortized performance, meaning that the average cost of operations is low O(1).
    • Cons: In worst case, an operation can take O(size) time rebuilding the queue as demonstrated in the Pop front 2 elements scenario.
  • pure/RealTimeQueue
    • Pros: Every operation is guaranteed to take at most O(1) time and space.
    • Cons: Poor amortized performance: Instruction cost is on average 3x for pop and 8x for push compared to pure/Queue.
  • mutable Queue
    • Pros: Also O(1) guarantees with a lower constant factor than pure/RealTimeQueue. Amortized performance is comparable to pure/Queue.
    • Cons: It is mutable and cannot be used in shared types (not shareable).

Instructions: ${\color{gray}0\%}$
Heap: ${\color{gray}0\%}$
Stable Memory: ${\color{gray}0\%}$
Garbage Collection: ${\color{gray}0\%}$

Instructions

pure/Queue pure/RealTimeQueue mutable Queue
Initialize with 2 elements 3_092 $({\color{gray}0\%})$ 2_304 $({\color{gray}0\%})$ 3_040 $({\color{gray}0\%})$
Push 500 elements 90_713 $({\color{gray}0\%})$ 744_219 $({\color{gray}0\%})$ 219_284 $({\color{gray}0\%})$
Pop front 2 elements 86_966 $({\color{gray}0\%})$ 4_446 $({\color{gray}0\%})$ 3_847 $({\color{gray}0\%})$
Pop 150 front&back 92_095 $({\color{gray}0\%})$ 304_908 $({\color{gray}0\%})$ 124_581 $({\color{gray}0\%})$

Heap

pure/Queue pure/RealTimeQueue mutable Queue
Initialize with 2 elements 324 B $({\color{gray}0\%})$ 300 B $({\color{gray}0\%})$ 352 B $({\color{gray}0\%})$
Push 500 elements 8.08 KiB $({\color{gray}0\%})$ 8.17 KiB $({\color{gray}0\%})$ 19.8 KiB $({\color{gray}0\%})$
Pop front 2 elements 240 B $({\color{gray}0\%})$ 240 B $({\color{gray}0\%})$ 192 B $({\color{gray}0\%})$
Pop 150 front&back -4.42 KiB $({\color{gray}0\%})$ -492 B $({\color{gray}0\%})$ -11.45 KiB $({\color{gray}0\%})$

Garbage Collection

pure/Queue pure/RealTimeQueue mutable Queue
Initialize with 2 elements 508 B $({\color{gray}0\%})$ 444 B $({\color{gray}0\%})$ 456 B $({\color{gray}0\%})$
Push 500 elements 10.1 KiB $({\color{gray}0\%})$ 137.84 KiB $({\color{gray}0\%})$ 344 B $({\color{gray}0\%})$
Pop front 2 elements 12.19 KiB $({\color{gray}0\%})$ 528 B $({\color{gray}0\%})$ 424 B $({\color{gray}0\%})$
Pop 150 front&back 15.61 KiB $({\color{gray}0\%})$ 49.66 KiB $({\color{gray}0\%})$ 12.1 KiB $({\color{gray}0\%})$

Note: Renamed benchmarks cannot be compared. Refer to the current baseline for manual comparison.

@rvanasa rvanasa changed the title Add binarySearch() function to Array and VarArray Add binarySearch() function to Array, VarArray, and List Aug 12, 2025
Copy link
Contributor

@crusso crusso left a comment

Choose a reason for hiding this comment

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

Nice!

@crusso crusso mentioned this pull request Aug 13, 2025
@christoph-dfinity
Copy link
Contributor

I'd maybe add a note about what happens if there are multiple equal elements. I'm assuming we don't make any guarantees which index of those we return?
Maybe a slightly more flexible return value would be { #found : Nat; #notFound : Nat }, where #notFound holds the index where the element would be inserted according to the ordering.

Here's the equivalent Rust documentation for reference: https://doc.rust-lang.org/std/vec/struct.Vec.html#method.binary_search

Go's slices.BinarySearch provides similar functionality: https://pkg.go.dev/golang.org/x/exp/slices#BinarySearch

Copy link
Contributor

@ggreif ggreif left a comment

Choose a reason for hiding this comment

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

LGTM, with the compressed code.

@Andrei1998
Copy link
Contributor

Andrei1998 commented Aug 13, 2025

Hi all, thanks for implementing this so quickly! I have a couple of thoughts in terms of the API design:

  1. I am on board with christoph-dfinity that we should provide a way to find where the element would be inserted in case it isn't found (or equivalent). This is a pretty standard use-case: in my experience, I would more often need to find the "largest index with value <= x" than "whether x exists or not" (the former isn't possible with the current version of the code, and also wasn't possible with Buffer.binarySearch()).
  2. I think one nice design for our binary search functions would be to instead have them in Int.mo and Nat.mo as functions that take (from, to, predicate) and find the first element in the corresponding range for which the given predicate evaluates to true. Users of the core library could then issue appropriate calls for anything of the form "find the first/last value that is less than/less than or equal to/greater than/greater than or equal to x." This would work for any reasonable data structure (by indexing into it inside the predicate) but also for more "abstract" predicates like when computing the square root of a number using binary search.
  3. If taking the approach in point 2, we could still choose to also provide container-specific functions for added efficiency (also making it easier for users to find them as a result).

@Andrei1998
Copy link
Contributor

Andrei1998 commented Aug 18, 2025

Hi all, thanks for implementing this so quickly! I have a couple of thoughts in terms of the API design:

  1. I am on board with christoph-dfinity that we should provide a way to find where the element would be inserted in case it isn't found (or equivalent). This is a pretty standard use-case: in my experience, I would more often need to find the "largest index with value <= x" than "whether x exists or not" (the former isn't possible with the current version of the code, and also wasn't possible with Buffer.binarySearch()).
  2. I think one nice design for our binary search functions would be to instead have them in Int.mo and Nat.mo as functions that take (from, to, predicate) and find the first element in the corresponding range for which the given predicate evaluates to true. Users of the core library could then issue appropriate calls for anything of the form "find the first/last value that is less than/less than or equal to/greater than/greater than or equal to x." This would work for any reasonable data structure (by indexing into it inside the predicate) but also for more "abstract" predicates like when computing the square root of a number using binary search.
  3. If taking the approach in point 2, we could still choose to also provide container-specific functions for added efficiency (also making it easier for users to find them as a result).

Following discussions with @rvanasa and @alexandru-uta, here is a PR following the design in point 2: #381.

I also opened an issue for this, hopefully not a duplicate: #382.

@rvanasa
Copy link
Contributor Author

rvanasa commented Aug 20, 2025

Updated the functions to return Result for consistency with Rust, similar to what was suggested by @christoph-dfinity. We could potentially use the names #found and #notFound for clarity, although #ok and #err is more composable with functions in the Result module.

Copy link
Contributor

@christoph-dfinity christoph-dfinity left a comment

Choose a reason for hiding this comment

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

Nicely done!

Copy link
Contributor

@AStepanov25 AStepanov25 left a comment

Choose a reason for hiding this comment

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

As for me the interface where binarySearch returns result is too complicated. It should return ?Nat, I think. If the user wants to know where the element is approximately located it's worth to introduce lowerBound and upperBound like in C++.

src/Array.mo Outdated
///
/// *Runtime and space assumes that `compare` runs in O(1) time and space.
public func binarySearch<T>(array : [T], compare : (T, T) -> Order.Order, element : T) : Types.Result<Nat, Nat> {
let size = array.size();
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is it worth to introduce size constant?

Copy link
Contributor

@Andrei1998 Andrei1998 Aug 25, 2025

Choose a reason for hiding this comment

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

I guess the interface is just dubbing Rust's way of doing this (which I also find a bit complicated interface-wise). Another thing I don't like about this interface is that we don't give any guarantees about which element we return in case there are multiple occurrences (the Rust docs explain this is for efficiency: you can early-stop the first time you find an element with the right value, which is also what the implementations in this PR do). I am personally in favor of C++-esque lower_bound functions, but I am biased towards C++. The current plan/idea is to also have some functions like this: #381. Maybe we can indeed split the functionality further based on how we think users will likely use these functions. Early stopping is actually good in cases where users have "the list has no duplicates" as an invariant in their code (but I can't tell how often this is the case versus lists with potential duplicates). I also can't tell immediately how often the users will be happy with getting an arbitrary index matching the required value versus the first/last one.

Copy link
Contributor Author

@rvanasa rvanasa Aug 25, 2025

Choose a reason for hiding this comment

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

Thanks for pointing this out; updated to remove the unnecessary size constant.

I see your point about the interface being complicated. Maybe a solution could be to show how to convert to an option in the doc comments, e.g. using Result.toOption(), for those migrating from Buffer.binarySearch().

Alternatively, I suppose we could roll back to the original implementation or even just defer to a Mops package containing these functions along with #381.

@timohanke
Copy link
Contributor

timohanke commented Sep 1, 2025

Why does it make sense to offer binarySearch for a raw Array/List where the user has direct control of the elements and can (inadvertently) corrupt the ordering? Shouldn't this be part of a new data structure that wraps around Array/List and that guarantees correct ordering at all times?

Or what's an application example where this is safe?

@Andrei1998
Copy link
Contributor

Andrei1998 commented Sep 1, 2025

Why does it make sense to offer binarySearch for a raw Array/List where the user has direct control of the elements and can (inadvertently) corrupt the ordering? Shouldn't this be part of a new data structure that wraps around Array/List and that guarantees correct ordering at all times?

Or what's an application example where this is safe?

Well, you could introduce a SortedX abstraction for various X (but I don't think this is a particularly good idea). I think it is pretty standard in many programming languages to have some functions that assume a precondition on the data structure (i.e., "if the list is sorted, the output is correct, otherwise, the function may do anything"). This is also the case for all functions that allow you to binary search on a range/array in other programming languages, so programmers should be very familiar with how to use this in a safe way.

Standard (practical) example is to (1) sort a list (2) then perform multiple binary searches on it (~on demand) to retrieve some entry. Here, ofc, one could argue that sorting should return a SortedList, and I agree semantically, but it's not idiomatic. Nothing that can't be done with a Map in this example, but it's significantly faster than a Map when you can guarantee sorting the list a priori. Probably the hardest function (in this PR) to find an application for is binary searching on a VarArray. This is rare, but it's the de-facto standard implementation for longest increasing subsequence (e.g., if you want to code this up in Motoko, which we did a couple of weeks back for https://leetcode.com/problems/russian-doll-envelopes/description/).

@rvanasa
Copy link
Contributor Author

rvanasa commented Sep 2, 2025

The Languages team discussed this today, and we decided to go with an API that is similar to Rust except with #found and #insertionIndex variants in place of Result. Given a large number of different design choices, this seems like the best compromise for interpretability + simplicity + consistency with other languages.

@rvanasa rvanasa merged commit 496a1e4 into main Sep 2, 2025
16 of 17 checks passed
@rvanasa rvanasa deleted the array-binary-search branch September 2, 2025 16:07
@Andrei1998
Copy link
Contributor

The Languages team discussed this today, and we decided to go with an API that is similar to Rust except with #found and #insertionIndex variants in place of Result. Given a large number of different design choices, this seems like the best compromise for interpretability + simplicity + consistency with other languages.

Nice, thanks for leading this and making a decision on the API! This means we can now decide on the API/name for #381, so anyone can tune in there if they want to.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants