Repository: locustio/locust

Issue:
Wrong distribution of virtual users by user classes
### Prerequisites

- [X] I am using [the latest version of Locust](https://github.com/locustio/locust/releases/)
- [X] I am reporting a bug, not asking a question

### Description

Hi, thanks for the awesome project. I came across the behavior of locust that is unclear to me. 
When I create two user classes `TestUser1` and `TestUser2` with weights of 20 and 25 respectively, the actual distribution shows that 23 and 22 virtual users were created respectively. The sum of 45 is identical, but it is the distribution, I consider, that is being performed incorrectly. I think there should be 20 VUsers for `TestUser1` and 25 for `TestUser2`.


[Documentation](https://docs.locust.io/en/stable/writing-a-locustfile.html#weight-and-fixed-count-attributes) says:
> If you wish to simulate more users of a certain type you can set a weight attribute on those classes.


Locust logs:
```bash
[2024-04-01 20:43:46,470] cab-wsm-0023164/INFO/locust.main: Starting web interface at http://0.0.0.0:8089
[2024-04-01 20:43:46,483] cab-wsm-0023164/INFO/locust.main: Starting Locust 2.24.1
[2024-04-01 20:43:50,643] cab-wsm-0023164/INFO/locust.runners: Ramping to 45 users at a rate of 50.00 per second
[2024-04-01 20:43:50,643] cab-wsm-0023164/DEBUG/locust.runners: Ramping to {"TestUser1": 23, "TestUser2": 22} (45 total users)
[2024-04-01 20:43:50,644] cab-wsm-0023164/DEBUG/locust.runners: Spawning additional {"TestUser1": 23, "TestUser2": 22} ({"TestUser1": 0, "TestUser2": 0} already running)...
[2024-04-01 20:43:50,645] cab-wsm-0023164/DEBUG/locust.runners: 10 users spawned
[2024-04-01 20:43:50,646] cab-wsm-0023164/DEBUG/locust.runners: 20 users spawned
[2024-04-01 20:43:50,646] cab-wsm-0023164/DEBUG/locust.runners: 23 users spawned
[2024-04-01 20:43:50,646] cab-wsm-0023164/DEBUG/locust.runners: All users of class TestUser1 spawned
[2024-04-01 20:43:50,647] cab-wsm-0023164/DEBUG/locust.runners: 33 users spawned
[2024-04-01 20:43:50,648] cab-wsm-0023164/DEBUG/locust.runners: 43 users spawned
[2024-04-01 20:43:50,648] cab-wsm-0023164/DEBUG/locust.runners: 45 users spawned
[2024-04-01 20:43:50,648] cab-wsm-0023164/DEBUG/locust.runners: All users of class TestUser2 spawned
[2024-04-01 20:43:50,648] cab-wsm-0023164/DEBUG/locust.runners: 0 users have been stopped, 45 still running
[2024-04-01 20:43:50,648] cab-wsm-0023164/INFO/locust.runners: All users spawned: {"TestUser1": 23, "TestUser2": 22} (45 total users)
```


### Command line

locust --host http://localhost:5000 --users 45 --spawn-rate 50 --loglevel "DEBUG"

### Locustfile contents

```python3
from locust import FastHttpUser, constant_pacing, task


class TestUser1(FastHttpUser):
    wait_time = constant_pacing(7200)
    weight = 20

    @task
    def hello_world(self):
        self.client.get("http://localhost:5000/1")


class TestUser2(FastHttpUser):
    wait_time = constant_pacing(7200)
    weight = 25

    @task
    def hello_world2(self):
        self.client.get("http://localhost:5000/2")
```


### Python version

3.11.6

### Locust version

2.24.1

### Operating system

MacOS 13.6.4


Discussion:
Thats very strange and a significant issue. Is it consistently 22/23?
The result is always reproduced with the same input data. The bug is consistently, not floating. I've done some tests, here's the result:
| # | Weight for `TestUser1` | Weight for `TestUser2` | Total VU | Expected VU for `TestUser1` | Expected VU for `TestUser2` | Actual VU for `TestUser1` | Actual VU for `TestUser2` | Is correct? |
|---|:----------------------:|:----------------------:|----------|-----------------------------|-----------------------------|:-------------------------:|:-------------------------:|-------------|
| 1 | 4                      | 5                      | 9        | 4                           | 5                           | 5                         | 4                         | No          |
| 2 | 5                      | 4                      | 9        | 5                           | 4                           | 5                         | 4                         | Yes         |
| 3 | 4                      | 5                      | 45       | 20                          | 25                          | 23                        | 22                        | No          |
| 4 | 8                      | 10                     | 18       | 8                           | 10                          | 9                         | 9                         | No          |
| 5 | 8                      | 10                     | 36       | 16                          | 20                          | 18                        | 18                        | No          |
| 6 | 20                     | 25                     | 45       | 20                          | 25                          | 23                        | 22                        | No          |
| 7 | 25                     | 20                     | 45       | 25                          | 20                          | 23                        | 22                        | No          |
| 8 | 2                      | 5                      | 7        | 2                           | 5                           | 2                         | 5                         | Yes         |
| 9 | 2                      | 3                      | 5        | 2                           | 3                           | 2                         | 3                         | Yes         |
When the VU ratio TestUser1/TestUser2 = 0.8, the distribution doesn't work correctly. It feels like the algorithm is trying to equate virtual users of two user classes (only if it's 4/5).
In # test 8 and 9 I tried the ratios 2/5 and 2/3 - everything works correctly.
Interesting (that’s one word for it, right? ;)

I’ll have a look at it once I’m back from holiday. @mboutet I dont know if you’re still around and have time to take a look :)

Resolving patch:
diff --git a/locust/dispatch.py b/locust/dispatch.py
--- a/locust/dispatch.py
+++ b/locust/dispatch.py
@@ -1,8 +1,10 @@
 from __future__ import annotations
 
 import contextlib
+import functools
 import itertools
 import math
+import sys
 import time
 from collections import defaultdict
 from operator import attrgetter
@@ -16,6 +18,20 @@
     from locust.runners import WorkerNode
 
 
+def compatible_math_gcd(*args: int) -> int:
+    """
+    This function is a workaround for the fact that `math.gcd` in:
+        - 3.5 <= Python < 3.9   doesn't accept more than two arguments.
+        - 3.9 <= Python         can accept more than two arguments.
+    See more at https://docs.python.org/3.9/library/math.html#math.gcd
+    """
+    if (3, 5) <= sys.version_info < (3, 9):
+        return functools.reduce(math.gcd, args)
+    elif sys.version_info >= (3, 9):
+        return math.gcd(*args)
+    raise NotImplementedError("This function is only implemented for Python from 3.5")
+
+
 # To profile line-by-line, uncomment the code below (i.e. `import line_profiler ...`) and
 # place `@profile` on the functions/methods you wish to profile. Then, in the unit test you are
 # running, use `from locust.dispatch import profile; profile.print_stats()` at the end of the unit test.
@@ -366,18 +382,37 @@ def infinite_cycle_gen(users: list[tuple[type[User], int]]) -> itertools.cycle:
             if not users:
                 return itertools.cycle([None])
 
-            # Normalize the weights so that the smallest weight will be equal to "target_min_weight".
-            # The value "2" was experimentally determined because it gave a better distribution especially
-            # when dealing with weights which are close to each others, e.g. 1.5, 2, 2.4, etc.
-            target_min_weight = 2
-
-            # 'Value' here means weight or fixed count
+            def _get_order_of_magnitude(n: float) -> int:
+                """Get how many times we need to multiply `n` to get an integer-like number.
+                For example:
+                    0.1 would return 10,
+                    0.04 would return 100,
+                    0.0007 would return 10000.
+                """
+                if n <= 0:
+                    raise ValueError("To get the order of magnitude, the number must be greater than 0.")
+
+                counter = 0
+                while n < 1:
+                    n *= 10
+                    counter += 1
+                return 10**counter
+
+            # Get maximum order of magnitude to "normalize the weights".
+            # "Normalizing the weights" is to multiply all weights by the same number so that
+            # they become integers. Then we can find the largest common divisor of all the
+            # weights, divide them by it and get the smallest possible numbers with the same
+            # ratio as the numbers originally had.
+            max_order_of_magnitude = _get_order_of_magnitude(min(abs(u[1]) for u in users))
+            weights = tuple(int(u[1] * max_order_of_magnitude) for u in users)
+
+            greatest_common_divisor = compatible_math_gcd(*weights)
             normalized_values = [
                 (
-                    user.__name__,
-                    round(target_min_weight * value / min(u[1] for u in users)),
+                    user[0].__name__,
+                    normalized_weight // greatest_common_divisor,
                 )
-                for user, value in users
+                for user, normalized_weight in zip(users, weights)
             ]
             generation_length_to_get_proper_distribution = sum(
                 normalized_val[1] for normalized_val in normalized_values


Test patch:
diff --git a/locust/test/test_dispatch.py b/locust/test/test_dispatch.py
--- a/locust/test/test_dispatch.py
+++ b/locust/test/test_dispatch.py
@@ -847,6 +847,75 @@ class User3(User):
         delta = time.perf_counter() - ts
         self.assertTrue(0 <= delta <= _TOLERANCE, delta)
 
+    def test_implementation_of_dispatch_distribution_with_gcd(self):
+        class User1(User):
+            weight = 4
+
+        class User2(User):
+            weight = 5
+
+        user_classes = [User1, User2]
+        worker_node1 = WorkerNode("1")
+
+        sleep_time = 0.2  # Speed-up test
+
+        users_dispatcher = UsersDispatcher(worker_nodes=[worker_node1], user_classes=user_classes)
+        users_dispatcher.new_dispatch(target_user_count=9, spawn_rate=9)
+
+        users_dispatcher._wait_between_dispatch = sleep_time
+
+        ts = time.perf_counter()
+        self.assertDictEqual(
+            next(users_dispatcher),
+            {
+                "1": {"User1": 4, "User2": 5},
+            },
+        )
+        delta = time.perf_counter() - ts
+        self.assertTrue(0 <= delta <= _TOLERANCE, delta)
+
+        ts = time.perf_counter()
+        self.assertRaises(StopIteration, lambda: next(users_dispatcher))
+        delta = time.perf_counter() - ts
+        self.assertTrue(0 <= delta <= _TOLERANCE, delta)
+
+    def test_implementation_of_dispatch_distribution_with_gcd_float_weight(self):
+        class User1(User):
+            weight = 0.8
+
+        class User2(User):
+            weight = 1
+
+        normalized_weights_to_min_int = 5  # User1: 0.8 * 5 = 4; User2: 1 * 5 = 5
+
+        user_classes = [User1, User2]
+        worker_node1 = WorkerNode("1")
+
+        sleep_time = 0.2  # Speed-up test
+
+        users_dispatcher = UsersDispatcher(worker_nodes=[worker_node1], user_classes=user_classes)
+        users_dispatcher.new_dispatch(target_user_count=18, spawn_rate=18)
+
+        users_dispatcher._wait_between_dispatch = sleep_time
+
+        ts = time.perf_counter()
+        self.assertDictEqual(
+            next(users_dispatcher),
+            {
+                "1": {
+                    "User1": int(normalized_weights_to_min_int * User1.weight * 2),
+                    "User2": int(normalized_weights_to_min_int * User2.weight * 2),
+                },
+            },
+        )
+        delta = time.perf_counter() - ts
+        self.assertTrue(0 <= delta <= _TOLERANCE, delta)
+
+        ts = time.perf_counter()
+        self.assertRaises(StopIteration, lambda: next(users_dispatcher))
+        delta = time.perf_counter() - ts
+        self.assertTrue(0 <= delta <= _TOLERANCE, delta)
+
 
 class TestWaitBetweenDispatch(unittest.TestCase):
     def test_wait_between_dispatch(self):