feat(model): hard-code per-backend ladder thresholds from ticket #19
The whisper.cpp ladder keyed both GPU backends to one provisional pair of thresholds. Issue #19 (comment 12913) measured each rung's transcribe-time VRAM delta on the installed build. The thresholds are those deltas plus the 512 MiB desktop headroom: - vulkan: q8_0 1644 MiB, q5_0 1359 MiB (was 1644 and 1358) - cuda: q8_0 1690 MiB, q5_0 1404 MiB select_ladder_rung and resolve_startup_rung take the installed build's backend. CPU and unknown backends keep the Vulkan thresholds; the RAM rule proposed in #19 is not implemented. The first-run default in config_manager now reads the memory of the installed build (VRAM for a GPU build, RAM otherwise). It used to read VRAM whenever an NVIDIA card was present, even on a CPU build.
This commit is contained in:
@@ -8,14 +8,13 @@ Rungs (quantized Turbo first, small floor last)::
|
||||
|
||||
large-v3-turbo-q8_0 -> large-v3-turbo-q5_0 -> small.en / small
|
||||
|
||||
The thresholds are PROVISIONAL. They are the rule
|
||||
``scripts/measure_perf_baseline.py::recommend_thresholds`` applies (measured
|
||||
footprint plus the 512 MiB desktop headroom) to the transcribe peaks
|
||||
recorded for ticket #19 (1132 MiB for q8_0, 846 MiB for q5_0). Ticket #19
|
||||
is still open and its measurement is not recorded in this repository, so
|
||||
these numbers are the calibration to replace, not evidence. A re-measure
|
||||
changes the constants here, and ``tests/test_model_ladder.py`` pins them so
|
||||
that change is deliberate.
|
||||
The thresholds are hard-coded from the ticket #19 installed-build measurement
|
||||
(issue #19, comment 12913): each rung's transcribe-time VRAM delta plus the
|
||||
512 MiB desktop headroom that ``scripts/measure_perf_baseline.py`` applies.
|
||||
They are per GPU backend, because CUDA's transcribe footprint is larger than
|
||||
Vulkan's on the same card. ``tests/test_model_ladder.py`` pins the constants
|
||||
and the measured inputs they derive from, so a re-measure changes them on
|
||||
purpose.
|
||||
|
||||
Backend order follows the same readiness rule: CUDA ranks before Vulkan
|
||||
only when a complete architecture-matching CUDA toolkit is installed
|
||||
@@ -47,14 +46,22 @@ LADDER_RUNGS: tuple[str, ...] = (DEFAULT_RUNG, LOW_MEMORY_RUNG)
|
||||
#: the baseline protocol adds to measured footprints.
|
||||
HEADROOM_MIB: float = 512.0
|
||||
|
||||
#: Provisional transcribe peaks (MiB) from ticket #19, not yet recorded in the repo.
|
||||
_MEASURED_Q8_0_PEAK_MIB: float = 1132.0
|
||||
_MEASURED_Q5_0_PEAK_MIB: float = 846.0
|
||||
#: Free-memory thresholds (MiB) per GPU backend, from ticket #19 (comment 12913):
|
||||
#: the measured transcribe-time VRAM delta plus HEADROOM_MIB.
|
||||
#: vulkan: q8_0 1132 + 512 = 1644, q5_0 847 + 512 = 1359
|
||||
#: cuda: q8_0 1178 + 512 = 1690, q5_0 892 + 512 = 1404
|
||||
_PREFER_ABOVE_MIB: dict[str, dict[str, float]] = {
|
||||
"vulkan": {DEFAULT_RUNG: 1644.0, LOW_MEMORY_RUNG: 1359.0},
|
||||
"cuda": {DEFAULT_RUNG: 1690.0, LOW_MEMORY_RUNG: 1404.0},
|
||||
}
|
||||
|
||||
#: Free memory at or above this keeps the default q8_0 rung.
|
||||
PREFER_Q8_0_ABOVE_MIB: float = _MEASURED_Q8_0_PEAK_MIB + HEADROOM_MIB
|
||||
#: Free memory at or above this keeps the q5_0 rung; below it is the floor.
|
||||
PREFER_Q5_0_ABOVE_MIB: float = _MEASURED_Q5_0_PEAK_MIB + HEADROOM_MIB
|
||||
#: Free memory at or above this keeps the default q8_0 rung on Vulkan.
|
||||
PREFER_Q8_0_ABOVE_MIB: float = _PREFER_ABOVE_MIB["vulkan"][DEFAULT_RUNG]
|
||||
#: Free memory at or above this keeps the q5_0 rung on Vulkan; below it is the floor.
|
||||
PREFER_Q5_0_ABOVE_MIB: float = _PREFER_ABOVE_MIB["vulkan"][LOW_MEMORY_RUNG]
|
||||
#: The CUDA thresholds, named for callers and tests that compare against them.
|
||||
PREFER_CUDA_Q8_0_ABOVE_MIB: float = _PREFER_ABOVE_MIB["cuda"][DEFAULT_RUNG]
|
||||
PREFER_CUDA_Q5_0_ABOVE_MIB: float = _PREFER_ABOVE_MIB["cuda"][LOW_MEMORY_RUNG]
|
||||
|
||||
#: Minimum CUDA compute capability (major, minor) the bundled backend
|
||||
#: targets. Older architectures cannot run its kernels.
|
||||
@@ -79,14 +86,31 @@ def _floor_rung(language_is_english: bool) -> str:
|
||||
return FLOOR_RUNG_ENGLISH if language_is_english else FLOOR_RUNG_MULTILINGUAL
|
||||
|
||||
|
||||
def select_ladder_rung(language_is_english: bool, free_mib: float) -> str:
|
||||
def _thresholds_for(gpu_backend: str) -> tuple[float, float]:
|
||||
"""Return ``(q8_0, q5_0)`` free-memory thresholds for the installed build's backend.
|
||||
|
||||
``cuda`` has its own pair. ``vulkan``, ``cpu`` and any unknown name share the
|
||||
Vulkan pair: a CPU build reads system RAM against the same rule as before.
|
||||
"""
|
||||
table = _PREFER_ABOVE_MIB.get(gpu_backend, _PREFER_ABOVE_MIB["vulkan"])
|
||||
return table[DEFAULT_RUNG], table[LOW_MEMORY_RUNG]
|
||||
|
||||
|
||||
def select_ladder_rung(
|
||||
language_is_english: bool,
|
||||
free_mib: float,
|
||||
gpu_backend: str = "vulkan",
|
||||
) -> str:
|
||||
"""Return the ladder rung that fits ``free_mib`` MiB of free memory.
|
||||
|
||||
Pure function of its inputs so the policy is testable without hardware.
|
||||
``gpu_backend`` is the backend the installed build has (``vulkan``, ``cuda``
|
||||
or ``cpu``); it selects the thresholds. Pure function of its inputs so the
|
||||
policy is testable without hardware.
|
||||
"""
|
||||
if free_mib >= PREFER_Q8_0_ABOVE_MIB:
|
||||
q8_threshold, q5_threshold = _thresholds_for(gpu_backend)
|
||||
if free_mib >= q8_threshold:
|
||||
return DEFAULT_RUNG
|
||||
if free_mib >= PREFER_Q5_0_ABOVE_MIB:
|
||||
if free_mib >= q5_threshold:
|
||||
return LOW_MEMORY_RUNG
|
||||
return _floor_rung(language_is_english)
|
||||
|
||||
@@ -95,15 +119,16 @@ def resolve_startup_rung(
|
||||
pinned_variant: str,
|
||||
language_is_english: bool,
|
||||
free_mib: float,
|
||||
gpu_backend: str = "vulkan",
|
||||
) -> str:
|
||||
"""Return the rung to load: a manual pin wins verbatim, otherwise the ladder.
|
||||
|
||||
A manual selection is never overridden here; only unpinned loads derive
|
||||
their rung from free memory.
|
||||
their rung from free memory, on the thresholds of ``gpu_backend``.
|
||||
"""
|
||||
if pinned_variant:
|
||||
return pinned_variant
|
||||
return select_ladder_rung(language_is_english, free_mib)
|
||||
return select_ladder_rung(language_is_english, free_mib, gpu_backend)
|
||||
|
||||
|
||||
def next_rung_down(rung: str, language_is_english: bool) -> str | None:
|
||||
@@ -354,6 +379,8 @@ __all__ = [
|
||||
"HEADROOM_MIB",
|
||||
"PREFER_Q8_0_ABOVE_MIB",
|
||||
"PREFER_Q5_0_ABOVE_MIB",
|
||||
"PREFER_CUDA_Q8_0_ABOVE_MIB",
|
||||
"PREFER_CUDA_Q5_0_ABOVE_MIB",
|
||||
"MIN_CUDA_COMPUTE_CAPABILITY",
|
||||
"GPU_BACKENDS",
|
||||
"select_ladder_rung",
|
||||
|
||||
@@ -776,13 +776,23 @@ class ConfigManager:
|
||||
# id, so first run loads large-v3-turbo-q8_0 where it fits (ticket
|
||||
# #20). Any explicit size or pin keeps the behavior below untouched.
|
||||
if not pinned and str(saved or "tiny").lower() == "tiny":
|
||||
from ..speech_recognition.model_ladder import free_memory_mib, resolve_startup_rung
|
||||
from ..speech_recognition.model_ladder import (
|
||||
free_memory_for_backend,
|
||||
resolve_startup_rung,
|
||||
)
|
||||
from ..speech_recognition.recognition_manager import detect_pywhispercpp_gpu_backend
|
||||
|
||||
language_id = str(sr_config.get("language", "auto"))
|
||||
language_info = cast(dict[str, str], SUPPORTED_LANGUAGES.get(language_id, {}))
|
||||
free_mib = free_memory_mib()
|
||||
# The memory the installed build uses (VRAM for a GPU build, RAM otherwise)
|
||||
# and the thresholds of that same backend, as the recommendation does.
|
||||
gpu_backend = detect_pywhispercpp_gpu_backend()
|
||||
free_mib, _source = free_memory_for_backend(gpu_backend)
|
||||
rung = resolve_startup_rung(
|
||||
"", language_info.get("whisper") == "en", free_mib if free_mib is not None else 0.0
|
||||
"",
|
||||
language_info.get("whisper") == "en",
|
||||
free_mib if free_mib is not None else 0.0,
|
||||
gpu_backend,
|
||||
)
|
||||
logger.info(
|
||||
"whisper.cpp startup rung %s (free memory %.0f MiB)",
|
||||
|
||||
@@ -571,7 +571,7 @@ def get_recommended_model() -> tuple[str, str]:
|
||||
if free_mib is None:
|
||||
logger.info("Free memory unreadable; recommending the floor model")
|
||||
return FLOOR_RUNG_MULTILINGUAL, "Free memory unreadable; floor model"
|
||||
rung = resolve_startup_rung("", False, free_mib)
|
||||
rung = resolve_startup_rung("", False, free_mib, gpu_backend)
|
||||
return rung, f"{rung}: {free_mib:.0f} MiB free {source} ({gpu_backend} build)"
|
||||
|
||||
|
||||
|
||||
@@ -527,7 +527,16 @@ class TestConfigManager(unittest.TestCase):
|
||||
# The migrated whisper.cpp default ("tiny", unpinned) resolves through
|
||||
# the memory-aware ladder instead of staying tiny (ticket #20): what
|
||||
# matters here is that vosk's "large" does not leak across engines.
|
||||
with patch("vocalinux.speech_recognition.model_ladder.free_memory_mib", return_value=100.0):
|
||||
with (
|
||||
patch(
|
||||
"vocalinux.speech_recognition.model_ladder.free_memory_for_backend",
|
||||
return_value=(100.0, "RAM"),
|
||||
),
|
||||
patch(
|
||||
"vocalinux.speech_recognition.recognition_manager.detect_pywhispercpp_gpu_backend",
|
||||
return_value="cpu",
|
||||
),
|
||||
):
|
||||
# "auto" language pairs the floor with the multilingual rung.
|
||||
self.assertEqual(config_manager.get_model_size_for_engine("whisper_cpp"), "small")
|
||||
self.assertEqual(config_manager.get_model_size_for_engine("whisper"), "tiny")
|
||||
|
||||
+92
-30
@@ -1,14 +1,14 @@
|
||||
"""Ticket #20: memory-aware model ladder, conditional backend order, retry.
|
||||
|
||||
All behavior is exercised with mocked probes and loaders: no GPU, no
|
||||
network, no hardware. The threshold-derivation test pins the rung
|
||||
thresholds to the provisional transcribe peaks (1132 MiB for q8_0, 846 MiB
|
||||
for q5_0, ticket #19, not yet recorded in the repo) plus the 512 MiB
|
||||
protocol headroom. Replacing them with a recorded measurement is a
|
||||
deliberate change to this test.
|
||||
network, no hardware. The threshold tests pin the per-backend rung thresholds
|
||||
to the ticket #19 installed-build measurement (issue #19, comment 12913): the
|
||||
transcribe-time VRAM delta of each rung plus the 512 MiB protocol headroom.
|
||||
Re-measuring changes those constants, and this file must change with them.
|
||||
"""
|
||||
|
||||
import os
|
||||
from contextlib import ExitStack
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -21,6 +21,8 @@ from vocalinux.speech_recognition.model_ladder import (
|
||||
FLOOR_RUNG_MULTILINGUAL,
|
||||
HEADROOM_MIB,
|
||||
LOW_MEMORY_RUNG,
|
||||
PREFER_CUDA_Q5_0_ABOVE_MIB,
|
||||
PREFER_CUDA_Q8_0_ABOVE_MIB,
|
||||
PREFER_Q5_0_ABOVE_MIB,
|
||||
PREFER_Q8_0_ABOVE_MIB,
|
||||
cuda_toolkit_ready_from_probe,
|
||||
@@ -38,22 +40,40 @@ def _size_mb(name: str) -> float:
|
||||
|
||||
|
||||
class TestThresholdDerivation:
|
||||
"""Thresholds match the baseline ticket's recorded recommendation."""
|
||||
"""Thresholds are the ticket #19 recorded transcribe-time deltas plus headroom.
|
||||
|
||||
#: Ticket #19 measured transcribe peaks (MiB) per rung.
|
||||
Q8_0_MEASURED_MIB: float = 1132.0
|
||||
Q5_0_MEASURED_MIB: float = 846.0
|
||||
The inputs are copied from issue #19, comment 12913 ("Per-rung load
|
||||
footprint", transcribe delta column), not recomputed from the code.
|
||||
"""
|
||||
|
||||
def test_q8_threshold_is_measured_footprint_plus_headroom(self) -> None:
|
||||
assert PREFER_Q8_0_ABOVE_MIB == self.Q8_0_MEASURED_MIB + HEADROOM_MIB
|
||||
#: Transcribe-time VRAM delta (MiB), peak minus before, per backend and rung.
|
||||
VULKAN_Q8_0_DELTA_MIB: float = 1132.0
|
||||
VULKAN_Q5_0_DELTA_MIB: float = 847.0
|
||||
CUDA_Q8_0_DELTA_MIB: float = 1178.0
|
||||
CUDA_Q5_0_DELTA_MIB: float = 892.0
|
||||
|
||||
def test_headroom_is_the_protocol_value(self) -> None:
|
||||
assert HEADROOM_MIB == 512.0
|
||||
|
||||
def test_vulkan_q8_threshold_is_measured_delta_plus_headroom(self) -> None:
|
||||
assert PREFER_Q8_0_ABOVE_MIB == self.VULKAN_Q8_0_DELTA_MIB + HEADROOM_MIB
|
||||
assert PREFER_Q8_0_ABOVE_MIB == 1644.0
|
||||
|
||||
def test_q5_threshold_is_measured_footprint_plus_headroom(self) -> None:
|
||||
assert PREFER_Q5_0_ABOVE_MIB == self.Q5_0_MEASURED_MIB + HEADROOM_MIB
|
||||
assert PREFER_Q5_0_ABOVE_MIB == 1358.0
|
||||
def test_vulkan_q5_threshold_is_measured_delta_plus_headroom(self) -> None:
|
||||
assert PREFER_Q5_0_ABOVE_MIB == self.VULKAN_Q5_0_DELTA_MIB + HEADROOM_MIB
|
||||
assert PREFER_Q5_0_ABOVE_MIB == 1359.0
|
||||
|
||||
def test_rungs_are_ordered(self) -> None:
|
||||
def test_cuda_q8_threshold_is_measured_delta_plus_headroom(self) -> None:
|
||||
assert PREFER_CUDA_Q8_0_ABOVE_MIB == self.CUDA_Q8_0_DELTA_MIB + HEADROOM_MIB
|
||||
assert PREFER_CUDA_Q8_0_ABOVE_MIB == 1690.0
|
||||
|
||||
def test_cuda_q5_threshold_is_measured_delta_plus_headroom(self) -> None:
|
||||
assert PREFER_CUDA_Q5_0_ABOVE_MIB == self.CUDA_Q5_0_DELTA_MIB + HEADROOM_MIB
|
||||
assert PREFER_CUDA_Q5_0_ABOVE_MIB == 1404.0
|
||||
|
||||
def test_rungs_are_ordered_on_each_backend(self) -> None:
|
||||
assert PREFER_Q8_0_ABOVE_MIB > PREFER_Q5_0_ABOVE_MIB
|
||||
assert PREFER_CUDA_Q8_0_ABOVE_MIB > PREFER_CUDA_Q5_0_ABOVE_MIB
|
||||
assert _size_mb(DEFAULT_RUNG) > _size_mb(LOW_MEMORY_RUNG)
|
||||
|
||||
|
||||
@@ -77,11 +97,44 @@ class TestSelectLadderRung:
|
||||
def test_recorded_boundaries_select_recorded_rungs(self) -> None:
|
||||
assert select_ladder_rung(True, 1644.0) == DEFAULT_RUNG
|
||||
assert select_ladder_rung(True, 1500.0) == LOW_MEMORY_RUNG
|
||||
assert select_ladder_rung(True, 1358.0) == LOW_MEMORY_RUNG
|
||||
assert select_ladder_rung(True, 1359.0) == LOW_MEMORY_RUNG
|
||||
assert select_ladder_rung(True, 1358.0) == FLOOR_RUNG_ENGLISH
|
||||
assert select_ladder_rung(True, 1200.0) == FLOOR_RUNG_ENGLISH
|
||||
assert select_ladder_rung(False, 1200.0) == FLOOR_RUNG_MULTILINGUAL
|
||||
|
||||
|
||||
class TestBackendThresholds:
|
||||
"""Each GPU backend keys to its own measured thresholds (ticket #19)."""
|
||||
|
||||
def test_cuda_keeps_default_only_above_its_own_q8_threshold(self) -> None:
|
||||
assert select_ladder_rung(True, 1690.0, "cuda") == DEFAULT_RUNG
|
||||
assert select_ladder_rung(True, 1689.0, "cuda") == LOW_MEMORY_RUNG
|
||||
# 1644 MiB is enough for Vulkan's q8_0 but not for CUDA's.
|
||||
assert select_ladder_rung(True, 1644.0, "cuda") == LOW_MEMORY_RUNG
|
||||
assert select_ladder_rung(True, 1644.0, "vulkan") == DEFAULT_RUNG
|
||||
|
||||
def test_cuda_q5_threshold_is_its_own(self) -> None:
|
||||
assert select_ladder_rung(True, 1404.0, "cuda") == LOW_MEMORY_RUNG
|
||||
assert select_ladder_rung(True, 1403.0, "cuda") == FLOOR_RUNG_ENGLISH
|
||||
assert select_ladder_rung(True, 1359.0, "vulkan") == LOW_MEMORY_RUNG
|
||||
assert select_ladder_rung(True, 1359.0, "cuda") == FLOOR_RUNG_ENGLISH
|
||||
|
||||
def test_cpu_and_unknown_backends_use_the_vulkan_thresholds(self) -> None:
|
||||
for backend in ("cpu", "unknown"):
|
||||
assert select_ladder_rung(True, 1644.0, backend) == DEFAULT_RUNG
|
||||
assert select_ladder_rung(True, 1359.0, backend) == LOW_MEMORY_RUNG
|
||||
assert select_ladder_rung(True, 1358.0, backend) == FLOOR_RUNG_ENGLISH
|
||||
|
||||
def test_default_backend_is_vulkan(self) -> None:
|
||||
assert select_ladder_rung(True, 1690.0) == DEFAULT_RUNG
|
||||
assert select_ladder_rung(True, 1644.0) == DEFAULT_RUNG
|
||||
|
||||
def test_resolve_passes_the_backend_through(self) -> None:
|
||||
assert resolve_startup_rung("", True, 1644.0, "cuda") == LOW_MEMORY_RUNG
|
||||
assert resolve_startup_rung("", True, 1644.0, "vulkan") == DEFAULT_RUNG
|
||||
assert resolve_startup_rung("tiny", True, 0.0, "cuda") == "tiny"
|
||||
|
||||
|
||||
class TestResolveStartupRung:
|
||||
"""A manual pin wins verbatim; only unpinned loads use the ladder."""
|
||||
|
||||
@@ -372,6 +425,24 @@ class TestStepDownRetry:
|
||||
assert loader.call_count == 1
|
||||
|
||||
|
||||
def _probe_memory(free_mib: float, backend: str = "cpu"):
|
||||
"""Stub the startup probes: free memory of the installed build, and its backend."""
|
||||
stack = ExitStack()
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"vocalinux.speech_recognition.model_ladder.free_memory_for_backend",
|
||||
return_value=(free_mib, "RAM"),
|
||||
)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"vocalinux.speech_recognition.recognition_manager.detect_pywhispercpp_gpu_backend",
|
||||
return_value=backend,
|
||||
)
|
||||
)
|
||||
return stack
|
||||
|
||||
|
||||
class TestStartupRungSelection:
|
||||
"""Unpinned defaults resolve through the ladder; explicit picks do not."""
|
||||
|
||||
@@ -390,9 +461,7 @@ class TestStartupRungSelection:
|
||||
|
||||
def test_unpinned_default_selects_rung_by_memory(self) -> None:
|
||||
manager = self._manager_with("tiny", "", "en-us")
|
||||
with patch(
|
||||
"vocalinux.speech_recognition.model_ladder.free_memory_mib", return_value=99999.0
|
||||
):
|
||||
with _probe_memory(99999.0):
|
||||
with patch(
|
||||
"vocalinux.utils.whispercpp_model_info.is_model_downloaded", return_value=False
|
||||
):
|
||||
@@ -400,10 +469,7 @@ class TestStartupRungSelection:
|
||||
|
||||
def test_unpinned_default_low_memory_selects_q5(self) -> None:
|
||||
manager = self._manager_with("tiny", "", "en-us")
|
||||
with patch(
|
||||
"vocalinux.speech_recognition.model_ladder.free_memory_mib",
|
||||
return_value=PREFER_Q5_0_ABOVE_MIB,
|
||||
):
|
||||
with _probe_memory(PREFER_Q5_0_ABOVE_MIB):
|
||||
with patch(
|
||||
"vocalinux.utils.whispercpp_model_info.is_model_downloaded", return_value=False
|
||||
):
|
||||
@@ -411,7 +477,7 @@ class TestStartupRungSelection:
|
||||
|
||||
def test_unpinned_default_tight_memory_selects_floor(self) -> None:
|
||||
manager = self._manager_with("tiny", "", "pl")
|
||||
with patch("vocalinux.speech_recognition.model_ladder.free_memory_mib", return_value=100.0):
|
||||
with _probe_memory(100.0):
|
||||
with patch(
|
||||
"vocalinux.utils.whispercpp_model_info.is_model_downloaded", return_value=False
|
||||
):
|
||||
@@ -419,16 +485,12 @@ class TestStartupRungSelection:
|
||||
|
||||
def test_pinned_variant_never_uses_ladder(self) -> None:
|
||||
manager = self._manager_with("tiny", LOW_MEMORY_RUNG, "en-us")
|
||||
with patch(
|
||||
"vocalinux.speech_recognition.model_ladder.free_memory_mib", return_value=99999.0
|
||||
):
|
||||
with _probe_memory(99999.0):
|
||||
assert manager.get_model_size_for_engine("whisper_cpp") == LOW_MEMORY_RUNG
|
||||
|
||||
def test_explicit_size_keeps_legacy_derivation(self) -> None:
|
||||
manager = self._manager_with("medium", "", "en-us")
|
||||
with patch(
|
||||
"vocalinux.speech_recognition.model_ladder.free_memory_mib", return_value=99999.0
|
||||
):
|
||||
with _probe_memory(99999.0):
|
||||
with patch(
|
||||
"vocalinux.utils.whispercpp_model_info.is_model_downloaded", return_value=False
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user