mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
770 lines
27 KiB
Erlang
770 lines
27 KiB
Erlang
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
-module(guild_member_list_bench).
|
|
-typing([eqwalizer]).
|
|
-include_lib("eunit/include/eunit.hrl").
|
|
|
|
-define(ONLINE_RATIO, 0.05).
|
|
-define(HOISTED_ROLES, [1000, 2000, 3000, 4000, 5000]).
|
|
|
|
bench_100k_test_() ->
|
|
{timeout, 120, fun() -> run_bench(100_000) end}.
|
|
|
|
bench_1m_test_() ->
|
|
case os:getenv("BENCH_SCALE") of
|
|
false -> {timeout, 1, fun() -> ok end};
|
|
_ -> {timeout, 600, fun() -> run_bench(1_000_000) end}
|
|
end.
|
|
|
|
bench_10m_test_() ->
|
|
case os:getenv("BENCH_SCALE") of
|
|
"10m" ++ _ -> {timeout, 1800, fun() -> run_bench(10_000_000) end};
|
|
"100m" ++ _ -> {timeout, 1800, fun() -> run_bench(10_000_000) end};
|
|
_ -> {timeout, 1, fun() -> ok end}
|
|
end.
|
|
|
|
bench_100m_test_() ->
|
|
case os:getenv("BENCH_SCALE") of
|
|
"100m" ++ _ -> {timeout, 7200, fun() -> run_bench(100_000_000) end};
|
|
_ -> {timeout, 1, fun() -> ok end}
|
|
end.
|
|
|
|
bench_presence_100k_test_() ->
|
|
{timeout, 60, fun() -> run_presence_bench(100_000) end}.
|
|
|
|
run_bench(N) ->
|
|
log("~n=== Benchmark: ~B members ===~n", [N]),
|
|
{GenUs, Members} = timer:tc(fun() -> generate_members(N) end),
|
|
log(" generate: ~.1f ms~n", [us_to_ms(GenUs)]),
|
|
Ref = guild_member_list_engine:new(),
|
|
run_bulk_load_benchmark(Ref, Members, N),
|
|
run_query_benchmarks(Ref, N),
|
|
run_mutation_benchmarks(Ref, N),
|
|
guild_member_list_engine:destroy(Ref),
|
|
log(" PASSED~n", []).
|
|
|
|
run_bulk_load_benchmark(Ref, Members, N) ->
|
|
{BulkUs, ok} = timer:tc(fun() ->
|
|
guild_member_list_engine:bulk_load(Ref, Members, ?HOISTED_ROLES)
|
|
end),
|
|
log(
|
|
" bulk_load: ~.1f ms (~B inserts/s)~n",
|
|
[us_to_ms(BulkUs), ops_per_second(N, BulkUs)]
|
|
),
|
|
|
|
Info = guild_member_list_engine:info(Ref),
|
|
TotalMB = maps:get(total_bytes, Info) / (1024.0 * 1024.0),
|
|
PerMember = maps:get(total_bytes, Info) / float(N),
|
|
log(" memory: ~.1f MB (~B bytes/member)~n", [TotalMB, trunc(PerMember)]).
|
|
|
|
run_query_benchmarks(Ref, N) ->
|
|
{CountUs, {Total, Online}} = timer:tc(fun() ->
|
|
guild_member_list_engine:get_counts(Ref)
|
|
end),
|
|
?assertEqual(N, Total),
|
|
log(
|
|
" get_counts: ~.1f us (total=~B, online=~B)~n",
|
|
[float(CountUs), Total, Online]
|
|
),
|
|
|
|
{GroupUs, Groups} = timer:tc(fun() ->
|
|
guild_member_list_engine:get_groups(Ref)
|
|
end),
|
|
log(
|
|
" get_groups: ~.1f us (~B sections)~n",
|
|
[float(GroupUs), length(Groups)]
|
|
),
|
|
bench_get_items(Ref, 0, 99, " get_items(0,99): ~.1f us (~B items)~n"),
|
|
bench_get_items(Ref, 5000, 5099, " get_items(5k,5099): ~.1f us (~B items)~n"),
|
|
bench_get_items(Ref, 50000, 50099, " get_items(50k,50099): ~.1f us (~B items)~n").
|
|
|
|
bench_get_items(Ref, First, Last, LogFormat) ->
|
|
{ItemsUs, Items} = timer:tc(fun() ->
|
|
guild_member_list_engine:get_items(Ref, First, Last)
|
|
end),
|
|
log(LogFormat, [float(ItemsUs), length(Items)]).
|
|
|
|
run_mutation_benchmarks(Ref, N) ->
|
|
{AddUs, _} = bench_avg(100, fun(I) ->
|
|
guild_member_list_engine:add_member(Ref, N + I, <<"bench_user">>, [1000], true)
|
|
end),
|
|
log(" add_member: ~.1f us avg~n", [AddUs]),
|
|
|
|
{RemUs, _} = bench_avg(100, fun(I) ->
|
|
guild_member_list_engine:remove_member(Ref, N + I)
|
|
end),
|
|
log(" remove_member: ~.1f us avg~n", [RemUs]),
|
|
|
|
lists:foreach(
|
|
fun(I) ->
|
|
guild_member_list_engine:add_member(Ref, N + I, <<"bench_user">>, [1000], false)
|
|
end,
|
|
lists:seq(1, 100)
|
|
),
|
|
|
|
{OnlineUs, _} = bench_avg(100, fun(I) ->
|
|
guild_member_list_engine:set_online(Ref, N + I, true)
|
|
end),
|
|
log(" set_online: ~.1f us avg~n", [OnlineUs]),
|
|
|
|
{HoistUs, _} = timer:tc(fun() ->
|
|
guild_member_list_engine:set_hoisted_roles(Ref, [1000, 2000, 3000, 4000, 5000, 6000])
|
|
end),
|
|
log(" set_hoisted_roles: ~.1f ms (full rebuild)~n", [us_to_ms(HoistUs)]),
|
|
|
|
{Total2, _Online2} = guild_member_list_engine:get_counts(Ref),
|
|
?assertEqual(N + 100, Total2).
|
|
|
|
run_presence_bench(N) ->
|
|
log("~n=== Presence Benchmark: ~B members ===~n", [N]),
|
|
|
|
Ref = guild_member_list_engine:new(),
|
|
Members = generate_members(N),
|
|
ok = guild_member_list_engine:bulk_load(Ref, Members, ?HOISTED_ROLES),
|
|
|
|
OnlineIds = [Id || {Id, _, _, true} <- Members],
|
|
ToggleIds = lists:sublist(OnlineIds, min(10000, length(OnlineIds))),
|
|
NToggle = length(ToggleIds),
|
|
|
|
{OffUs, _} = bench_presence_toggle(Ref, ToggleIds, false),
|
|
log_presence_toggle(" toggle ~B offline: ~.1f ms (~B/s)~n", NToggle, OffUs),
|
|
{OnUs, _} = bench_presence_toggle(Ref, ToggleIds, true),
|
|
log_presence_toggle(" toggle ~B online: ~.1f ms (~B/s)~n", NToggle, OnUs),
|
|
guild_member_list_engine:destroy(Ref),
|
|
log(" PASSED~n", []).
|
|
|
|
bench_presence_toggle(Ref, ToggleIds, Online) ->
|
|
timer:tc(fun() ->
|
|
lists:foreach(
|
|
fun(Id) ->
|
|
guild_member_list_engine:set_online(Ref, Id, Online)
|
|
end,
|
|
ToggleIds
|
|
)
|
|
end).
|
|
|
|
log_presence_toggle(Format, Count, Us) ->
|
|
log(Format, [Count, us_to_ms(Us), ops_per_second(Count, Us)]).
|
|
|
|
bench_concurrent_100k_test_() ->
|
|
{timeout, 120, fun() -> run_concurrent_bench(100_000) end}.
|
|
|
|
run_concurrent_bench(N) ->
|
|
log("~n=== Concurrent Read Benchmark: ~B members ===~n", [N]),
|
|
{Ref, _Members} = loaded_engine(N),
|
|
ReadCount = 5000,
|
|
Parent = self(),
|
|
ReaderPids = spawn_readers(Ref, Parent, ReadCount),
|
|
CounterPids = spawn_counters(Ref, Parent),
|
|
ReaderResults = collect_results(ReaderPids, reader_done),
|
|
CounterResults = collect_results(CounterPids, counter_done),
|
|
log_result_group(
|
|
" 4 readers (~B get_items each):~n", [ReadCount], ReadCount, ReaderResults
|
|
),
|
|
log_result_group(" 2 counter readers (10K each):~n", [], 10000, CounterResults),
|
|
{Total, Online} = guild_member_list_engine:get_counts(Ref),
|
|
?assertEqual(N, Total),
|
|
log(" final: total=~B, online=~B~n", [Total, Online]),
|
|
guild_member_list_engine:destroy(Ref),
|
|
log(" PASSED~n", []).
|
|
|
|
loaded_engine(N) ->
|
|
Ref = guild_member_list_engine:new(),
|
|
Members = generate_members(N),
|
|
ok = guild_member_list_engine:bulk_load(Ref, Members, ?HOISTED_ROLES),
|
|
{Ref, Members}.
|
|
|
|
spawn_readers(Ref, Parent, ReadCount) ->
|
|
[spawn_link(fun() -> reader_benchmark(Ref, Parent, ReadCount) end) || _ <- lists:seq(1, 4)].
|
|
|
|
reader_benchmark(Ref, Parent, ReadCount) ->
|
|
T0 = erlang:monotonic_time(microsecond),
|
|
lists:foreach(fun(J) -> read_item_range(Ref, J) end, lists:seq(1, ReadCount)),
|
|
T1 = erlang:monotonic_time(microsecond),
|
|
Parent ! {reader_done, self(), T1 - T0}.
|
|
|
|
read_item_range(Ref, J) ->
|
|
Start = (J * 100) rem 10000,
|
|
_ = guild_member_list_engine:get_items(Ref, Start, Start + 99).
|
|
|
|
spawn_counters(Ref, Parent) ->
|
|
[spawn_link(fun() -> counter_benchmark(Ref, Parent) end) || _ <- lists:seq(1, 2)].
|
|
|
|
counter_benchmark(Ref, Parent) ->
|
|
T0 = erlang:monotonic_time(microsecond),
|
|
lists:foreach(fun(_) -> read_counts_and_groups(Ref) end, lists:seq(1, 10000)),
|
|
T1 = erlang:monotonic_time(microsecond),
|
|
Parent ! {counter_done, self(), T1 - T0}.
|
|
|
|
read_counts_and_groups(Ref) ->
|
|
_ = guild_member_list_engine:get_counts(Ref),
|
|
_ = guild_member_list_engine:get_groups(Ref).
|
|
|
|
log_result_group(HeaderFormat, HeaderArgs, OpCount, Results) ->
|
|
log(HeaderFormat, HeaderArgs),
|
|
lists:foreach(
|
|
fun({_, Us}) ->
|
|
log(" ~.1f ms (~B ops/s)~n", [us_to_ms(Us), ops_per_second(OpCount, Us)])
|
|
end,
|
|
Results
|
|
).
|
|
|
|
collect_results(Pids, Tag) ->
|
|
[
|
|
receive
|
|
{Tag, Pid, Us} -> {Pid, Us}
|
|
after 120000 ->
|
|
error({collect_results_timeout, Tag, Pid})
|
|
end
|
|
|| Pid <- Pids
|
|
].
|
|
|
|
bench_latency_100k_test_() ->
|
|
{timeout, 120, fun() -> run_latency_bench(100_000) end}.
|
|
|
|
run_latency_bench(N) ->
|
|
log("~n=== Latency Percentile Benchmark: ~B members ===~n", [N]),
|
|
{Ref, Members} = loaded_engine(N),
|
|
OnlineIds = lists:sublist([Id || {Id, _, _, true} <- Members], 1000),
|
|
AddLatencies = [measure_add_latency(Ref, N, I) || I <- lists:seq(1, 1000)],
|
|
report_percentiles("add_member", AddLatencies),
|
|
lists:foreach(
|
|
fun(I) -> guild_member_list_engine:remove_member(Ref, N + I) end, lists:seq(1, 1000)
|
|
),
|
|
ToggleLatencies = [measure_toggle_latency(Ref, Id) || Id <- OnlineIds],
|
|
report_percentiles("set_online", ToggleLatencies),
|
|
GetItemsLatencies = [measure_get_items_latency(Ref) || _ <- lists:seq(1, 1000)],
|
|
report_percentiles("get_items(0,99)", GetItemsLatencies),
|
|
CountLatencies = [measure_count_latency(Ref) || _ <- lists:seq(1, 10000)],
|
|
report_percentiles("get_counts", CountLatencies),
|
|
guild_member_list_engine:destroy(Ref),
|
|
log(" PASSED~n", []).
|
|
|
|
measure_add_latency(Ref, N, I) ->
|
|
T0 = erlang:monotonic_time(microsecond),
|
|
guild_member_list_engine:add_member(Ref, N + I, <<"lat_user">>, [1000], true),
|
|
erlang:monotonic_time(microsecond) - T0.
|
|
|
|
measure_toggle_latency(Ref, Id) ->
|
|
T0 = erlang:monotonic_time(microsecond),
|
|
guild_member_list_engine:set_online(Ref, Id, false),
|
|
T1 = erlang:monotonic_time(microsecond),
|
|
guild_member_list_engine:set_online(Ref, Id, true),
|
|
T1 - T0.
|
|
|
|
measure_get_items_latency(Ref) ->
|
|
T0 = erlang:monotonic_time(microsecond),
|
|
_ = guild_member_list_engine:get_items(Ref, 0, 99),
|
|
erlang:monotonic_time(microsecond) - T0.
|
|
|
|
measure_count_latency(Ref) ->
|
|
T0 = erlang:monotonic_time(microsecond),
|
|
_ = guild_member_list_engine:get_counts(Ref),
|
|
erlang:monotonic_time(microsecond) - T0.
|
|
|
|
report_percentiles(Label, Latencies) ->
|
|
Sorted = lists:sort(Latencies),
|
|
Len = length(Sorted),
|
|
P50 = lists:nth(max(1, Len div 2), Sorted),
|
|
P95 = lists:nth(max(1, trunc(Len * 0.95)), Sorted),
|
|
P99 = lists:nth(max(1, trunc(Len * 0.99)), Sorted),
|
|
Max = lists:last(Sorted),
|
|
log(
|
|
" ~s: p50=~Bus p95=~Bus p99=~Bus max=~Bus~n",
|
|
[Label, P50, P95, P99, Max]
|
|
).
|
|
|
|
bench_hotpath_100k_test_() ->
|
|
{timeout, 180, fun() -> run_hotpath_bench(100_000) end}.
|
|
|
|
bench_hotpath_1m_test_() ->
|
|
case os:getenv("BENCH_SCALE") of
|
|
false -> {timeout, 1, fun() -> ok end};
|
|
_ -> {timeout, 600, fun() -> run_hotpath_bench(1_000_000) end}
|
|
end.
|
|
|
|
run_hotpath_bench(N) ->
|
|
log("~n=== Hot-path Benchmark (CPU + allocations): ~B members ===~n", [N]),
|
|
{Ref, Members} = loaded_engine(N),
|
|
{Total, Online} = guild_member_list_engine:get_counts(Ref),
|
|
Offline = Total - Online,
|
|
log(" total=~B online=~B offline=~B (offline solidly hidden)~n", [Total, Online, Offline]),
|
|
OnlineIds = online_ids(Members),
|
|
bench_index_hotpath(Ref, OnlineIds),
|
|
bench_count_hotpath(Ref),
|
|
bench_move_hotpath(Ref, OnlineIds),
|
|
bench_read_hotpath(Ref, Total, Online),
|
|
bench_sync_encode_once_vs_per_subscriber(Ref),
|
|
ok = assert_flat(Ref, Online),
|
|
guild_member_list_engine:destroy(Ref),
|
|
log("~n PASSED (no O(N) hotspot on the move/read hot path)~n", []),
|
|
ok.
|
|
|
|
online_ids(Members) ->
|
|
[Id || {Id, _, _, true} <- Members].
|
|
|
|
bench_index_hotpath(Ref, OnlineIds) ->
|
|
Shallow = hd(OnlineIds),
|
|
Deep = lists:last(OnlineIds),
|
|
log("~n -- rank: index_of (O(log N), flat across position) --~n", []),
|
|
bench_op("index_of shallow", fun() -> guild_member_list_engine:index_of(Ref, Shallow) end),
|
|
bench_op("index_of deep ", fun() -> guild_member_list_engine:index_of(Ref, Deep) end).
|
|
|
|
bench_count_hotpath(Ref) ->
|
|
log("~n -- gate: get_counts (must be O(1), near-zero alloc) --~n", []),
|
|
bench_op("get_counts ", fun() -> guild_member_list_engine:get_counts(Ref) end),
|
|
bench_op("get_groups ", fun() -> guild_member_list_engine:get_groups(Ref) end).
|
|
|
|
bench_move_hotpath(Ref, OnlineIds) ->
|
|
log("~n -- engine move: set_online (O(log N)) --~n", []),
|
|
MoveId = hd(OnlineIds),
|
|
bench_op("set_online off ", fun() ->
|
|
guild_member_list_engine:set_online(Ref, MoveId, false)
|
|
end),
|
|
bench_op("set_online on ", fun() ->
|
|
guild_member_list_engine:set_online(Ref, MoveId, true)
|
|
end).
|
|
|
|
bench_read_hotpath(Ref, Total, Online) ->
|
|
log("~n -- read: windowed get_items (flat across offset, no hotspot) --~n", []),
|
|
bench_op("get_items(0,99) ", fun() -> guild_member_list_engine:get_items(Ref, 0, 99) end),
|
|
Mid = max(0, (Online div 2)),
|
|
bench_op("get_items(mid,+99)", fun() ->
|
|
guild_member_list_engine:get_items(Ref, Mid, Mid + 99)
|
|
end),
|
|
Near = max(0, Online - 100),
|
|
bench_op("get_items(end,+99)", fun() ->
|
|
guild_member_list_engine:get_items(Ref, Near, Near + 99)
|
|
end),
|
|
log("~n -- full snapshot vs window read --~n", []),
|
|
bench_op_slow("full snapshot ", fun() ->
|
|
guild_member_list_engine:get_items(Ref, 0, Total * 2)
|
|
end),
|
|
bench_op("window(0,99) ", fun() -> guild_member_list_engine:get_items(Ref, 0, 99) end),
|
|
log("~n -- dispatch encode: shared SYNC payload vs per-subscriber encode --~n", []),
|
|
ok.
|
|
|
|
bench_sync_encode_once_vs_per_subscriber(Ref) ->
|
|
Groups = guild_member_list_engine:get_groups(Ref),
|
|
Items = [
|
|
#{<<"member">> => #{<<"user">> => #{<<"id">> => <<"123">>, <<"username">> => <<"u">>}}}
|
|
],
|
|
Resp = #{
|
|
<<"guild_id">> => <<"1">>,
|
|
<<"id">> => <<"123">>,
|
|
<<"groups">> => [#{<<"id">> => Id, <<"count">> => C} || {Id, C} <- Groups],
|
|
<<"ops">> => [#{<<"op">> => <<"SYNC">>, <<"range">> => [7, 7], <<"items">> => Items}]
|
|
},
|
|
Subs = 1000,
|
|
EncodeFun = fun() -> iolist_to_binary(json:encode(Resp)) end,
|
|
OnceUs = median_us(EncodeFun),
|
|
OnceWords = measure_words(EncodeFun),
|
|
log(" encode once: ~B us ~B words~n", [OnceUs, OnceWords]),
|
|
log(
|
|
" vs per-subscriber (~B subs): re-encoding ~B x costs ~B us + ~B words;"
|
|
" shared SYNC wire sends the same binary~n",
|
|
[Subs, Subs, OnceUs * Subs, OnceWords * Subs]
|
|
).
|
|
|
|
assert_flat(Ref, Online) ->
|
|
Mid = max(0, Online div 2),
|
|
Near = max(0, Online - 100),
|
|
T0 = median_us(fun() -> guild_member_list_engine:get_items(Ref, 0, 99) end),
|
|
T1 = median_us(fun() -> guild_member_list_engine:get_items(Ref, Mid, Mid + 99) end),
|
|
T2 = median_us(fun() -> guild_member_list_engine:get_items(Ref, Near, Near + 99) end),
|
|
Worst = lists:max([T0, T1, T2]),
|
|
Best = max(1, lists:min([T0, T1, T2])),
|
|
Ratio = Worst / Best,
|
|
log(" flatness: shallow=~Bus mid=~Bus deep=~Bus (worst/best=~.1fx)~n", [T0, T1, T2, Ratio]),
|
|
?assert(Ratio < 5.0),
|
|
ok.
|
|
|
|
median_us(Fun) ->
|
|
Samples = lists:sort([element(1, timer:tc(Fun)) || _ <- lists:seq(1, 21)]),
|
|
lists:nth(11, Samples).
|
|
|
|
bench_op(Label, Fun) ->
|
|
Reds = measure_reds(Fun),
|
|
Words = measure_words(Fun),
|
|
Us = median_us(Fun),
|
|
log(" ~s : ~8B us ~12B reds ~12B words~n", [Label, Us, Reds, Words]).
|
|
|
|
bench_op_slow(Label, Fun) ->
|
|
Reds = measure_reds(Fun),
|
|
{Us, _} = timer:tc(Fun),
|
|
log(" ~s : ~8B us ~12B reds (full-structure walk; allocs ~~ O(N))~n", [Label, Us, Reds]).
|
|
|
|
measure_reds(Fun) ->
|
|
erlang:garbage_collect(),
|
|
{reductions, R0} = erlang:process_info(self(), reductions),
|
|
_ = Fun(),
|
|
{reductions, R1} = erlang:process_info(self(), reductions),
|
|
max(0, R1 - R0 - 1).
|
|
|
|
measure_words(Fun) ->
|
|
Iterations = 2000,
|
|
Parent = self(),
|
|
_ = spawn(fun() ->
|
|
erlang:garbage_collect(),
|
|
{_, R0, _} = erlang:statistics(garbage_collection),
|
|
run_n(Fun, Iterations),
|
|
erlang:garbage_collect(),
|
|
{_, R1, _} = erlang:statistics(garbage_collection),
|
|
Parent ! {words, (R1 - R0) div Iterations}
|
|
end),
|
|
receive
|
|
{words, W} -> W
|
|
after 120000 -> error(measure_words_timeout)
|
|
end.
|
|
|
|
run_n(_Fun, 0) ->
|
|
ok;
|
|
run_n(Fun, K) ->
|
|
_ = Fun(),
|
|
run_n(Fun, K - 1).
|
|
|
|
bench_dispatch_scaling_test_() ->
|
|
{timeout, 300, fun run_dispatch_scaling_bench/0}.
|
|
|
|
run_dispatch_scaling_bench() ->
|
|
log("~n=== Dispatch scaling: shared SYNC wire vs per-subscriber SYNC encode ===~n", []),
|
|
Sink = spawn(fun sink_loop/0),
|
|
SyncPayload = sample_sync_payload(100),
|
|
SyncWire = iolist_to_binary(json:encode(SyncPayload)),
|
|
log(
|
|
" payload bytes: full SYNC window(100)=~B~n",
|
|
[byte_size(SyncWire)]
|
|
),
|
|
lists:foreach(
|
|
fun(S) -> bench_dispatch_scale(Sink, SyncPayload, S) end,
|
|
[1000, 10000, 100000]
|
|
),
|
|
Sink ! stop,
|
|
log(
|
|
" PASSED (shared SYNC dispatch avoids per-subscriber encode work)~n",
|
|
[]
|
|
),
|
|
ok.
|
|
|
|
bench_dispatch_scale(Sink, SyncPayload, SubscriberCount) ->
|
|
Pids = lists:duplicate(SubscriberCount, Sink),
|
|
{SharedUs, _} = timer:tc(fun() ->
|
|
Enc = iolist_to_binary(json:encode(SyncPayload)),
|
|
gateway_dispatch_relay:dispatch_many(Pids, guild_member_list_update, Enc)
|
|
end),
|
|
{PerSubscriberUs, _} = timer:tc(fun() ->
|
|
dispatch_per_subscriber_sync(Pids, SyncPayload)
|
|
end),
|
|
log_dispatch_scale(SubscriberCount, SharedUs, PerSubscriberUs).
|
|
|
|
dispatch_per_subscriber_sync(Pids, SyncPayload) ->
|
|
lists:foreach(
|
|
fun(Pid) ->
|
|
Enc = iolist_to_binary(json:encode(SyncPayload)),
|
|
gateway_dispatch_relay:dispatch(Pid, guild_member_list_update, Enc)
|
|
end,
|
|
Pids
|
|
).
|
|
|
|
log_dispatch_scale(SubscriberCount, SharedUs, PerSubscriberUs) ->
|
|
log(
|
|
" S=~7B shared=~9.1f ms (~.3f us/sub)"
|
|
" per-sub=~9.1f ms (~.3f us/sub) per-sub/shared=~.1fx~n",
|
|
[
|
|
SubscriberCount,
|
|
us_to_ms(SharedUs),
|
|
SharedUs / SubscriberCount,
|
|
us_to_ms(PerSubscriberUs),
|
|
PerSubscriberUs / SubscriberCount,
|
|
PerSubscriberUs / max(1, SharedUs)
|
|
]
|
|
).
|
|
|
|
sink_loop() ->
|
|
receive
|
|
stop -> ok;
|
|
_ -> sink_loop()
|
|
after 300000 ->
|
|
ok
|
|
end.
|
|
|
|
sample_member(U) ->
|
|
UB = integer_to_binary(U),
|
|
#{
|
|
<<"user">> => #{
|
|
<<"id">> => UB,
|
|
<<"username">> => <<"user_", UB/binary>>,
|
|
<<"global_name">> => <<"User ", UB/binary>>,
|
|
<<"avatar">> => <<"abcd1234efgh5678">>,
|
|
<<"discriminator">> => <<"0001">>,
|
|
<<"flags">> => 0
|
|
},
|
|
<<"nick">> => null,
|
|
<<"roles">> => [],
|
|
<<"joined_at">> => <<"2026-01-01T00:00:00Z">>,
|
|
<<"presence">> => #{<<"status">> => <<"online">>, <<"mobile">> => false}
|
|
}.
|
|
|
|
sample_groups() ->
|
|
[#{<<"id">> => <<"online">>, <<"count">> => 5000}].
|
|
|
|
sample_sync_payload(W) ->
|
|
Items = [#{<<"member">> => sample_member(U)} || U <- lists:seq(1, W)],
|
|
#{
|
|
<<"guild_id">> => <<"1">>,
|
|
<<"id">> => <<"123">>,
|
|
<<"channel_id">> => <<"123">>,
|
|
<<"member_count">> => 5000,
|
|
<<"online_count">> => 5000,
|
|
<<"groups">> => sample_groups(),
|
|
<<"ops">> => [
|
|
#{<<"op">> => <<"SYNC">>, <<"range">> => [0, W - 1], <<"items">> => Items}
|
|
]
|
|
}.
|
|
|
|
bench_batched_member_list_fanout_test_() ->
|
|
{timeout, 180, fun run_batched_member_list_fanout_bench/0}.
|
|
|
|
run_batched_member_list_fanout_bench() ->
|
|
log("~n=== Batched member-list fanout benchmark ===~n", []),
|
|
with_batched_fanout_relay_mock(fun() ->
|
|
bench_batched_fanout_case(identical_ranges, 16, 128, 5000),
|
|
bench_batched_fanout_case(unique_ranges, 16, 128, 5000)
|
|
end),
|
|
log(" PASSED (backend batch coalesces list fanout and preserves range grouping)~n", []).
|
|
|
|
bench_batched_fanout_case(RangeMode, ChannelCount, SessionsPerChannel, MemberCount) ->
|
|
{State, SubsTab} = batched_fanout_state(
|
|
RangeMode, ChannelCount, SessionsPerChannel, MemberCount
|
|
),
|
|
ExpectedDispatches = expected_batched_fanout_dispatches(
|
|
RangeMode, ChannelCount, SessionsPerChannel
|
|
),
|
|
try
|
|
{Us, FlushedState} = timer:tc(fun() ->
|
|
{ok, QueuedState} = guild_member_list:broadcast_all_member_list_updates(State),
|
|
guild_member_list:flush_pending_member_list_syncs(QueuedState)
|
|
end),
|
|
{Dispatches, Recipients, Bytes} = collect_batched_fanout_dispatches(
|
|
ExpectedDispatches
|
|
),
|
|
assert_no_batched_fanout_dispatch(),
|
|
?assertEqual(ExpectedDispatches, Dispatches),
|
|
?assertEqual(ChannelCount * SessionsPerChannel, Recipients),
|
|
?assert(Bytes > 0),
|
|
log(
|
|
" ~p: channels=~B sessions/channel=~B members=~B dispatches=~B"
|
|
" recipients=~B bytes=~B elapsed=~.1f ms~n",
|
|
[
|
|
RangeMode,
|
|
ChannelCount,
|
|
SessionsPerChannel,
|
|
MemberCount,
|
|
Dispatches,
|
|
Recipients,
|
|
Bytes,
|
|
us_to_ms(Us)
|
|
]
|
|
),
|
|
FlushedState
|
|
after
|
|
guild_member_list_subs:destroy(SubsTab),
|
|
guild_member_list_channel_engine:destroy_all(State)
|
|
end.
|
|
|
|
expected_batched_fanout_dispatches(identical_ranges, ChannelCount, _SessionsPerChannel) ->
|
|
ChannelCount;
|
|
expected_batched_fanout_dispatches(unique_ranges, ChannelCount, SessionsPerChannel) ->
|
|
ChannelCount * SessionsPerChannel.
|
|
|
|
batched_fanout_state(RangeMode, ChannelCount, SessionsPerChannel, MemberCount) ->
|
|
GuildId = 424242,
|
|
ChannelIds = lists:seq(700000, 700000 + ChannelCount - 1),
|
|
MemberIds = lists:seq(1, MemberCount),
|
|
Members = [bench_member(UserId) || UserId <- MemberIds],
|
|
SubsTab = guild_member_list_subs:new(),
|
|
Sessions = batched_fanout_sessions(
|
|
RangeMode, ChannelIds, SessionsPerChannel, SubsTab, MemberIds
|
|
),
|
|
State = #{
|
|
id => GuildId,
|
|
member_count => MemberCount,
|
|
sessions => Sessions,
|
|
connected_user_ids => sets:from_list(MemberIds),
|
|
member_presence => maps:from_list([
|
|
{UserId, #{<<"status">> => <<"online">>, <<"mobile">> => false}}
|
|
|| UserId <- MemberIds
|
|
]),
|
|
member_list_subscriptions => SubsTab,
|
|
data => #{
|
|
<<"guild">> => #{
|
|
<<"id">> => integer_to_binary(GuildId),
|
|
<<"owner_id">> => <<"1">>,
|
|
<<"features">> => [],
|
|
<<"member_count">> => MemberCount
|
|
},
|
|
<<"roles">> => [bench_everyone_role(GuildId)],
|
|
<<"channels">> => [bench_channel(ChannelId) || ChannelId <- ChannelIds],
|
|
<<"members">> => Members
|
|
},
|
|
channel_member_list_engines => #{}
|
|
},
|
|
{State, SubsTab}.
|
|
|
|
batched_fanout_sessions(RangeMode, ChannelIds, SessionsPerChannel, SubsTab, MemberIds) ->
|
|
ViewableChannels = maps:from_list([{ChannelId, true} || ChannelId <- ChannelIds]),
|
|
maps:from_list(
|
|
lists:append([
|
|
batched_fanout_channel_sessions(
|
|
RangeMode, ChannelId, SessionsPerChannel, SubsTab, MemberIds, ViewableChannels
|
|
)
|
|
|| ChannelId <- ChannelIds
|
|
])
|
|
).
|
|
|
|
batched_fanout_channel_sessions(
|
|
RangeMode, ChannelId, SessionsPerChannel, SubsTab, MemberIds, ViewableChannels
|
|
) ->
|
|
[
|
|
batched_fanout_session_entry(
|
|
RangeMode, ChannelId, SessionIdx, SubsTab, MemberIds, ViewableChannels
|
|
)
|
|
|| SessionIdx <- lists:seq(1, SessionsPerChannel)
|
|
].
|
|
|
|
batched_fanout_session_entry(
|
|
RangeMode, ChannelId, SessionIdx, SubsTab, MemberIds, ViewableChannels
|
|
) ->
|
|
SessionId = batched_fanout_session_id(ChannelId, SessionIdx),
|
|
Ranges = batched_fanout_ranges(RangeMode, SessionIdx),
|
|
{_OldRanges, _ShouldSync} = guild_member_list_subs:subscribe(
|
|
SessionId, integer_to_binary(ChannelId), Ranges, SubsTab
|
|
),
|
|
{SessionId, #{
|
|
pid => self(),
|
|
user_id => lists:nth(((SessionIdx - 1) rem length(MemberIds)) + 1, MemberIds),
|
|
viewable_channels => ViewableChannels
|
|
}}.
|
|
|
|
batched_fanout_session_id(ChannelId, SessionIdx) ->
|
|
<<"bench_", (integer_to_binary(ChannelId))/binary, "_",
|
|
(integer_to_binary(SessionIdx))/binary>>.
|
|
|
|
batched_fanout_ranges(identical_ranges, _SessionIdx) ->
|
|
[{0, 99}];
|
|
batched_fanout_ranges(unique_ranges, SessionIdx) ->
|
|
Start = SessionIdx * 2,
|
|
[{Start, Start}].
|
|
|
|
bench_everyone_role(GuildId) ->
|
|
#{
|
|
<<"id">> => GuildId,
|
|
<<"name">> => <<"everyone">>,
|
|
<<"permissions">> =>
|
|
constants:view_channel_permission() bor constants:view_channel_members_permission(),
|
|
<<"hoist">> => false,
|
|
<<"position">> => 0
|
|
}.
|
|
|
|
bench_channel(ChannelId) ->
|
|
#{
|
|
<<"id">> => ChannelId,
|
|
<<"name">> => <<"bench">>,
|
|
<<"type">> => 0,
|
|
<<"permission_overwrites">> => []
|
|
}.
|
|
|
|
bench_member(UserId) ->
|
|
UserIdBin = integer_to_binary(UserId),
|
|
#{
|
|
<<"user">> => #{
|
|
<<"id">> => UserIdBin,
|
|
<<"username">> => <<"bench_user_", UserIdBin/binary>>
|
|
},
|
|
<<"roles">> => []
|
|
}.
|
|
|
|
with_batched_fanout_relay_mock(Fun) ->
|
|
meck:new(gateway_dispatch_relay, [passthrough, no_link]),
|
|
Parent = self(),
|
|
Ref = make_ref(),
|
|
put(batched_fanout_ref, Ref),
|
|
meck:expect(
|
|
gateway_dispatch_relay,
|
|
dispatch_many,
|
|
fun(Pids, guild_member_list_update, Payload, _GuildId) ->
|
|
Parent ! {batched_fanout_dispatch, Ref, length(Pids), payload_bytes(Payload)},
|
|
ok
|
|
end
|
|
),
|
|
try
|
|
Fun()
|
|
after
|
|
erase(batched_fanout_ref),
|
|
meck:unload(gateway_dispatch_relay)
|
|
end.
|
|
|
|
payload_bytes({pre_encoded, Bin}) when is_binary(Bin) ->
|
|
byte_size(Bin);
|
|
payload_bytes(Bin) when is_binary(Bin) ->
|
|
byte_size(Bin);
|
|
payload_bytes(_) ->
|
|
0.
|
|
|
|
collect_batched_fanout_dispatches(ExpectedCount) ->
|
|
Ref = get(batched_fanout_ref),
|
|
collect_batched_fanout_dispatches(ExpectedCount, Ref, 0, 0, 0).
|
|
|
|
collect_batched_fanout_dispatches(0, _Ref, Dispatches, Recipients, Bytes) ->
|
|
{Dispatches, Recipients, Bytes};
|
|
collect_batched_fanout_dispatches(ExpectedCount, Ref, Dispatches, Recipients, Bytes) ->
|
|
receive
|
|
{batched_fanout_dispatch, Ref, PidCount, PayloadBytes} ->
|
|
collect_batched_fanout_dispatches(
|
|
ExpectedCount - 1,
|
|
Ref,
|
|
Dispatches + 1,
|
|
Recipients + PidCount,
|
|
Bytes + PayloadBytes
|
|
)
|
|
after 30000 ->
|
|
error({batched_fanout_dispatch_timeout, ExpectedCount})
|
|
end.
|
|
|
|
assert_no_batched_fanout_dispatch() ->
|
|
Ref = get(batched_fanout_ref),
|
|
receive
|
|
{batched_fanout_dispatch, Ref, PidCount, PayloadBytes} ->
|
|
?assert(false, {unexpected_batched_fanout_dispatch, PidCount, PayloadBytes})
|
|
after 100 ->
|
|
ok
|
|
end.
|
|
|
|
generate_members(N) ->
|
|
NumHoisted = length(?HOISTED_ROLES),
|
|
[
|
|
{I, iolist_to_binary(io_lib:format("user_~10..0B", [I])),
|
|
case I rem (NumHoisted + 3) of
|
|
R when R < NumHoisted -> [lists:nth(R + 1, ?HOISTED_ROLES)];
|
|
_ -> []
|
|
end,
|
|
rand:uniform() < ?ONLINE_RATIO}
|
|
|| I <- lists:seq(1, N)
|
|
].
|
|
|
|
bench_avg(Iterations, Fun) ->
|
|
{TotalUs, Results} = timer:tc(fun() ->
|
|
[Fun(I) || I <- lists:seq(1, Iterations)]
|
|
end),
|
|
AvgUs = TotalUs / float(Iterations),
|
|
{AvgUs, Results}.
|
|
|
|
us_to_ms(Us) -> Us / 1000.0.
|
|
us_to_s(Us) -> Us / 1_000_000.0.
|
|
|
|
ops_per_second(Count, Us) -> trunc(Count / us_to_s(Us)).
|
|
|
|
log(Fmt, Args) ->
|
|
Msg = lists:flatten(io_lib:format(Fmt, Args)),
|
|
?debugMsg(Msg).
|