Skip to content

Commit 64d3bc5

Browse files
authored
Merge pull request #2663 from llirrikk/fix/dispatch-distribution
fix(dispatch): UserClasses weight distribution with gcd
2 parents 31d957e + 278f214 commit 64d3bc5

File tree

2 files changed

+113
-9
lines changed

2 files changed

+113
-9
lines changed

Diff for: locust/dispatch.py

+44-9
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from __future__ import annotations
22

33
import contextlib
4+
import functools
45
import itertools
56
import math
7+
import sys
68
import time
79
from collections import defaultdict
810
from operator import attrgetter
@@ -16,6 +18,20 @@
1618
from locust.runners import WorkerNode
1719

1820

21+
def compatible_math_gcd(*args: int) -> int:
22+
"""
23+
This function is a workaround for the fact that `math.gcd` in:
24+
- 3.5 <= Python < 3.9 doesn't accept more than two arguments.
25+
- 3.9 <= Python can accept more than two arguments.
26+
See more at https://docs.python.org/3.9/library/math.html#math.gcd
27+
"""
28+
if (3, 5) <= sys.version_info < (3, 9):
29+
return functools.reduce(math.gcd, args)
30+
elif sys.version_info >= (3, 9):
31+
return math.gcd(*args)
32+
raise NotImplementedError("This function is only implemented for Python from 3.5")
33+
34+
1935
# To profile line-by-line, uncomment the code below (i.e. `import line_profiler ...`) and
2036
# place `@profile` on the functions/methods you wish to profile. Then, in the unit test you are
2137
# 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:
366382
if not users:
367383
return itertools.cycle([None])
368384

369-
# Normalize the weights so that the smallest weight will be equal to "target_min_weight".
370-
# The value "2" was experimentally determined because it gave a better distribution especially
371-
# when dealing with weights which are close to each others, e.g. 1.5, 2, 2.4, etc.
372-
target_min_weight = 2
373-
374-
# 'Value' here means weight or fixed count
385+
def _get_order_of_magnitude(n: float) -> int:
386+
"""Get how many times we need to multiply `n` to get an integer-like number.
387+
For example:
388+
0.1 would return 10,
389+
0.04 would return 100,
390+
0.0007 would return 10000.
391+
"""
392+
if n <= 0:
393+
raise ValueError("To get the order of magnitude, the number must be greater than 0.")
394+
395+
counter = 0
396+
while n < 1:
397+
n *= 10
398+
counter += 1
399+
return 10**counter
400+
401+
# Get maximum order of magnitude to "normalize the weights".
402+
# "Normalizing the weights" is to multiply all weights by the same number so that
403+
# they become integers. Then we can find the largest common divisor of all the
404+
# weights, divide them by it and get the smallest possible numbers with the same
405+
# ratio as the numbers originally had.
406+
max_order_of_magnitude = _get_order_of_magnitude(min(abs(u[1]) for u in users))
407+
weights = tuple(int(u[1] * max_order_of_magnitude) for u in users)
408+
409+
greatest_common_divisor = compatible_math_gcd(*weights)
375410
normalized_values = [
376411
(
377-
user.__name__,
378-
round(target_min_weight * value / min(u[1] for u in users)),
412+
user[0].__name__,
413+
normalized_weight // greatest_common_divisor,
379414
)
380-
for user, value in users
415+
for user, normalized_weight in zip(users, weights)
381416
]
382417
generation_length_to_get_proper_distribution = sum(
383418
normalized_val[1] for normalized_val in normalized_values

Diff for: locust/test/test_dispatch.py

+69
Original file line numberDiff line numberDiff line change
@@ -847,6 +847,75 @@ class User3(User):
847847
delta = time.perf_counter() - ts
848848
self.assertTrue(0 <= delta <= _TOLERANCE, delta)
849849

850+
def test_implementation_of_dispatch_distribution_with_gcd(self):
851+
class User1(User):
852+
weight = 4
853+
854+
class User2(User):
855+
weight = 5
856+
857+
user_classes = [User1, User2]
858+
worker_node1 = WorkerNode("1")
859+
860+
sleep_time = 0.2 # Speed-up test
861+
862+
users_dispatcher = UsersDispatcher(worker_nodes=[worker_node1], user_classes=user_classes)
863+
users_dispatcher.new_dispatch(target_user_count=9, spawn_rate=9)
864+
865+
users_dispatcher._wait_between_dispatch = sleep_time
866+
867+
ts = time.perf_counter()
868+
self.assertDictEqual(
869+
next(users_dispatcher),
870+
{
871+
"1": {"User1": 4, "User2": 5},
872+
},
873+
)
874+
delta = time.perf_counter() - ts
875+
self.assertTrue(0 <= delta <= _TOLERANCE, delta)
876+
877+
ts = time.perf_counter()
878+
self.assertRaises(StopIteration, lambda: next(users_dispatcher))
879+
delta = time.perf_counter() - ts
880+
self.assertTrue(0 <= delta <= _TOLERANCE, delta)
881+
882+
def test_implementation_of_dispatch_distribution_with_gcd_float_weight(self):
883+
class User1(User):
884+
weight = 0.8
885+
886+
class User2(User):
887+
weight = 1
888+
889+
normalized_weights_to_min_int = 5 # User1: 0.8 * 5 = 4; User2: 1 * 5 = 5
890+
891+
user_classes = [User1, User2]
892+
worker_node1 = WorkerNode("1")
893+
894+
sleep_time = 0.2 # Speed-up test
895+
896+
users_dispatcher = UsersDispatcher(worker_nodes=[worker_node1], user_classes=user_classes)
897+
users_dispatcher.new_dispatch(target_user_count=18, spawn_rate=18)
898+
899+
users_dispatcher._wait_between_dispatch = sleep_time
900+
901+
ts = time.perf_counter()
902+
self.assertDictEqual(
903+
next(users_dispatcher),
904+
{
905+
"1": {
906+
"User1": int(normalized_weights_to_min_int * User1.weight * 2),
907+
"User2": int(normalized_weights_to_min_int * User2.weight * 2),
908+
},
909+
},
910+
)
911+
delta = time.perf_counter() - ts
912+
self.assertTrue(0 <= delta <= _TOLERANCE, delta)
913+
914+
ts = time.perf_counter()
915+
self.assertRaises(StopIteration, lambda: next(users_dispatcher))
916+
delta = time.perf_counter() - ts
917+
self.assertTrue(0 <= delta <= _TOLERANCE, delta)
918+
850919

851920
class TestWaitBetweenDispatch(unittest.TestCase):
852921
def test_wait_between_dispatch(self):

0 commit comments

Comments
 (0)