Repository: open-telemetry/opentelemetry-python

Issue:
Consider using binary search for ExplicitBucketHistogram
https://github.com/open-telemetry/opentelemetry-python/blob/dfb5c66ae310001bb40326f6286345b7fa252aba/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/aggregation.py#L129-L134

Could use some benchmarks, but this is likely to perform better with binary search (builtin `bisect` module) above a certain number of buckets. Also, using a list instead of `OrderedDict` may be faster


Discussion:
Yes, good idea :+1: 

Assigning to me :v:
Yes, this should make histogram performance better, here is a small test:

```python
from collections import OrderedDict
from math import inf
from random import randint, seed
from timeit import Timer
from bisect import bisect

BOUNDARIES_LIMIT = 1000
RANDOM_NUMBERS_AMOUNT = 10000

seed(0)

random_numbers = [
    randint(0, BOUNDARIES_LIMIT) for _ in range(0, BOUNDARIES_LIMIT)
]


class OrderedDictValue:

    def __init__(self):
        self._value = OrderedDict(
            [(key, 0) for key in (*range(0, BOUNDARIES_LIMIT, 10), inf)]
        )

    def aggregate(self, value):
        for key in self._value.keys():
            if value < key:
                self._value[key] = self._value[key] + value


class ListValue:

    def __init__(self):
        self._value = [
            [key, 0] for key in (*range(0, BOUNDARIES_LIMIT, 10), inf)
        ]

    def aggregate(self, value):
        for key in self._value:
            if value < key[0]:
                key[1] = key[1] + 1
                break


class BinarySearchListValue:

    def __init__(self):

        self._boundaries = [
            boundary for boundary in (*range(0, BOUNDARIES_LIMIT, 10), inf)
        ]
        self._values = [0 for boundary in self._boundaries]

    def aggregate(self, value):

        index = bisect(self._boundaries, value)

        self._values[index] = self._values[index] + 1


ordered_dict_value = OrderedDictValue()
list_value = ListValue()
binary_search_list_value = BinarySearchListValue()


def ordered_dict_aggregate():

    for random_number in random_numbers:
        ordered_dict_value.aggregate(random_number)


def list_aggregate():

    for random_number in random_numbers:
        list_value.aggregate(random_number)


def binary_search_list_aggregate():

    for random_number in random_numbers:
        binary_search_list_value.aggregate(random_number)


print(Timer(ordered_dict_aggregate).timeit(100) / 100)
print(Timer(list_aggregate).timeit(100) / 100)
print(Timer(binary_search_list_aggregate).timeit(100) / 100)
```
```
0.010334961380067398
0.0020147767799790018
0.00040475784000591373
```

Resolving patch:
diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/aggregation.py b/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/aggregation.py
--- a/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/aggregation.py
+++ b/opentelemetry-sdk/src/opentelemetry/sdk/_metrics/aggregation.py
@@ -13,7 +13,7 @@
 # limitations under the License.
 
 from abc import ABC, abstractmethod
-from collections import OrderedDict
+from bisect import bisect_left
 from logging import getLogger
 from math import inf
 from threading import Lock
@@ -156,13 +156,14 @@ def __init__(
         record_min_max: bool = True,
     ):
         super().__init__()
-        self._value = OrderedDict([(key, 0) for key in (*boundaries, inf)])
+        # pylint: disable=unnecessary-comprehension
+        self._boundaries = [boundary for boundary in (*boundaries, inf)]
+        self.value = [0 for _ in range(len(self._boundaries))]
         self._min = inf
         self._max = -inf
         self._sum = 0
         self._record_min_max = record_min_max
         self._start_time_unix_nano = _time_ns()
-        self._boundaries = boundaries
 
     def aggregate(self, measurement: Measurement) -> None:
 
@@ -174,12 +175,7 @@ def aggregate(self, measurement: Measurement) -> None:
 
         self._sum += value
 
-        for key in self._value.keys():
-
-            if value < key:
-                self._value[key] = self._value[key] + 1
-
-                break
+        self.value[bisect_left(self._boundaries, value)] += 1
 
     def collect(self) -> Optional[Histogram]:
         """
@@ -188,18 +184,16 @@ def collect(self) -> Optional[Histogram]:
         now = _time_ns()
 
         with self._lock:
-            value = self._value
+            value = self.value
             start_time_unix_nano = self._start_time_unix_nano
 
-            self._value = OrderedDict(
-                [(key, 0) for key in (*self._boundaries, inf)]
-            )
+            self.value = [0 for _ in range(len(self._boundaries))]
             self._start_time_unix_nano = now + 1
 
         return Histogram(
             start_time_unix_nano=start_time_unix_nano,
             time_unix_nano=now,
-            bucket_counts=tuple(value.values()),
+            bucket_counts=tuple(value),
             explicit_bounds=self._boundaries,
             aggregation_temporality=AggregationTemporality.DELTA,
         )


Test patch:
diff --git a/opentelemetry-sdk/tests/metrics/test_aggregation.py b/opentelemetry-sdk/tests/metrics/test_aggregation.py
--- a/opentelemetry-sdk/tests/metrics/test_aggregation.py
+++ b/opentelemetry-sdk/tests/metrics/test_aggregation.py
@@ -216,24 +216,32 @@ def test_collect(self):
 class TestExplicitBucketHistogramAggregation(TestCase):
     def test_aggregate(self):
         """
-        `ExplicitBucketHistogramAggregation` collects data for
-        explicit bucket histogram metric points
+        Test `ExplicitBucketHistogramAggregation with custom boundaries
         """
 
         explicit_bucket_histogram_aggregation = (
-            ExplicitBucketHistogramAggregation()
+            ExplicitBucketHistogramAggregation(boundaries=[0, 2, 4])
         )
 
         explicit_bucket_histogram_aggregation.aggregate(Measurement(-1))
+        explicit_bucket_histogram_aggregation.aggregate(Measurement(0))
+        explicit_bucket_histogram_aggregation.aggregate(Measurement(1))
         explicit_bucket_histogram_aggregation.aggregate(Measurement(2))
-        explicit_bucket_histogram_aggregation.aggregate(Measurement(7))
-        explicit_bucket_histogram_aggregation.aggregate(Measurement(8))
-        explicit_bucket_histogram_aggregation.aggregate(Measurement(9999))
+        explicit_bucket_histogram_aggregation.aggregate(Measurement(3))
+        explicit_bucket_histogram_aggregation.aggregate(Measurement(4))
+        explicit_bucket_histogram_aggregation.aggregate(Measurement(5))
+
+        # The first bucket keeps count of values between (-inf, 0] (-1 and 0)
+        self.assertEqual(explicit_bucket_histogram_aggregation.value[0], 2)
+
+        # The second bucket keeps count of values between (0, 2] (1 and 2)
+        self.assertEqual(explicit_bucket_histogram_aggregation.value[1], 2)
 
-        self.assertEqual(explicit_bucket_histogram_aggregation._value[0], 1)
-        self.assertEqual(explicit_bucket_histogram_aggregation._value[5], 1)
-        self.assertEqual(explicit_bucket_histogram_aggregation._value[10], 2)
-        self.assertEqual(explicit_bucket_histogram_aggregation._value[inf], 1)
+        # The third bucket keeps count of values between (2, 4] (3 and 4)
+        self.assertEqual(explicit_bucket_histogram_aggregation.value[2], 2)
+
+        # The fourth bucket keeps count of values between (4, inf) (3 and 4)
+        self.assertEqual(explicit_bucket_histogram_aggregation.value[3], 1)
 
     def test_min_max(self):
         """
@@ -273,15 +281,13 @@ def test_collect(self):
         """
 
         explicit_bucket_histogram_aggregation = (
-            ExplicitBucketHistogramAggregation()
+            ExplicitBucketHistogramAggregation(boundaries=[0, 1, 2])
         )
 
         explicit_bucket_histogram_aggregation.aggregate(Measurement(1))
         first_histogram = explicit_bucket_histogram_aggregation.collect()
 
-        self.assertEqual(
-            first_histogram.bucket_counts, (0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0)
-        )
+        self.assertEqual(first_histogram.bucket_counts, (0, 1, 0, 0))
 
         # CI fails the last assertion without this
         sleep(0.1)
@@ -289,9 +295,7 @@ def test_collect(self):
         explicit_bucket_histogram_aggregation.aggregate(Measurement(1))
         second_histogram = explicit_bucket_histogram_aggregation.collect()
 
-        self.assertEqual(
-            second_histogram.bucket_counts, (0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0)
-        )
+        self.assertEqual(second_histogram.bucket_counts, (0, 1, 0, 0))
 
         self.assertGreater(
             second_histogram.time_unix_nano, first_histogram.time_unix_nano