-
Notifications
You must be signed in to change notification settings - Fork 2k
fix(campplus): bound the clustering thread usage and stop over-computing the spectrum #3699
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8d2ed4b
52705e6
e6c574b
0d7b3f7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -53,6 +53,110 @@ def _resolve_ncpu(config, fallback=4): | |
| return max(value, 1) | ||
|
|
||
|
|
||
| # The active BLAS limit, if any. Held for the life of the process rather than | ||
| # scoped to a call -- see `_limit_blas_threads` for why. | ||
| _blas_thread_limiter = None | ||
| _blas_thread_limit = None | ||
|
|
||
|
|
||
| def _current_blas_threads(): | ||
| """Thread count currently configured for BLAS, or None if unreadable.""" | ||
| try: | ||
| import threadpoolctl | ||
| except ImportError: | ||
| return None | ||
| counts = { | ||
| pool["num_threads"] | ||
| for pool in threadpoolctl.threadpool_info() | ||
| if pool["user_api"] == "blas" | ||
| } | ||
| return counts.pop() if len(counts) == 1 else None | ||
|
|
||
|
|
||
| def _limit_blas_threads(ncpu): | ||
| """Apply `ncpu` to BLAS as well as to torch. | ||
|
|
||
| `ncpu` documents itself as the thread count for "CPU 内部操作并行性", but | ||
| it only ever reached `torch.set_num_threads`. The speaker-clustering path | ||
| does not run on torch at all: `scipy.linalg.eigh` on the affinity | ||
| Laplacian goes to BLAS, whose own default is one thread per core. So on a | ||
| many-core host the CPU is saturated by a library `ncpu` never touched. | ||
| Measured on a 64-core host with a 357-row Laplacian: | ||
|
|
||
| BLAS threads wall CPU | ||
| 64 1.20s 68.9 core-seconds | ||
| 4 0.05s 2.8 core-seconds | ||
| 1 0.03s 1.8 core-seconds | ||
|
|
||
| At a few hundred rows the parallel driver spends its time synchronising | ||
| rather than computing, so bounding it is a latency win as well as a CPU | ||
| one. | ||
|
|
||
| The update follows `torch.set_num_threads`: the newest `ncpu` wins, and no | ||
| set is issued when BLAS already sits at that value. Both settings are | ||
| process-wide, so constructing a second `AutoModel` applies its `ncpu` to | ||
| any model already built in this process -- including ones without speaker | ||
| clustering. That is the same contract `torch.set_num_threads` already | ||
| has; the difference is only that `ncpu` used to leave BLAS untouched. | ||
|
|
||
| The limit is not released once applied. It is process-global, so a scoped | ||
| enter/exit pair per request could be interleaved by overlapping requests | ||
| and leave the setting unbalanced, and FunASR reaches BLAS only through | ||
| small one-shot operations. Doing nothing when threadpoolctl is missing | ||
| leaves BLAS at its own default rather than failing model construction. | ||
| """ | ||
| global _blas_thread_limiter, _blas_thread_limit | ||
|
|
||
| current = _current_blas_threads() | ||
| if current is not None and current == ncpu: | ||
| return | ||
|
|
||
| # `threadpool_limits` records the OpenMP pools as well as the BLAS ones | ||
| # even when entered with `user_api="blas"`, and its `__exit__` restores | ||
| # every pool it captured. Dropping the previous limiter would therefore | ||
| # roll torch's thread count back to whatever it was when that limiter was | ||
| # created -- undoing the `torch.set_num_threads(ncpu)` that `build_model` | ||
| # has already performed. Save and restore it across the swap so replacing | ||
| # the BLAS limit cannot touch an OpenMP setting it does not own. | ||
| torch_threads = torch.get_num_threads() | ||
|
|
||
| if _blas_thread_limiter is not None: | ||
| _blas_thread_limiter.__exit__(None, None, None) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Preserve torch's current thread setting when replacing the BLAS limiter. On supported threadpoolctl 3.5.0, this exit restores all captured controllers, including torch's OpenMP pool, even though the limit was entered with user_api='blas'. build_model has already called torch.set_num_threads(ncpu), so actual sequential builds 8 -> 1 end with config/BLAS=1 but torch=8; 1 -> 8 ends with config/BLAS=8 but torch=1. Both base controls keep torch at the requested final count. Please avoid restoring unrelated OpenMP state and cover both settings through build_model, not only the helper. |
||
| _blas_thread_limiter = None | ||
|
|
||
| try: | ||
| import threadpoolctl | ||
| except ImportError: | ||
| logging.info( | ||
| "threadpoolctl is not installed, so `ncpu` cannot be applied to " | ||
| "BLAS; speaker clustering may use one thread per core." | ||
| ) | ||
| return | ||
|
|
||
| _blas_thread_limiter = threadpoolctl.threadpool_limits( | ||
| limits=ncpu, user_api="blas" | ||
| ) | ||
| _blas_thread_limiter.__enter__() | ||
| _blas_thread_limit = ncpu | ||
|
|
||
| if torch.get_num_threads() != torch_threads: | ||
| torch.set_num_threads(torch_threads) | ||
|
|
||
| # BLAS pools are only visible to threadpoolctl once a library backed by | ||
| # them has been loaded. funasr imports such a library at package import, | ||
| # so they are present by the time a model is built -- but confirm rather | ||
| # than assume, because a dropped limit here would silently restore the | ||
| # CPU saturation this is meant to prevent. | ||
| applied = _current_blas_threads() | ||
| if applied is not None and applied != ncpu: | ||
| logging.warning( | ||
| "ncpu=%s could not be applied to BLAS (it reports %s); " | ||
| "speaker clustering may use more threads than requested.", | ||
| ncpu, | ||
| applied, | ||
| ) | ||
|
|
||
|
|
||
| def _join_vad_texts(texts): | ||
| """Remove rich tags and join VAD text without adding spaces between Chinese chunks.""" | ||
| cleaned = [re.sub(r"<\|[^|]*\|>", "", text).strip() for text in texts] | ||
|
|
@@ -570,6 +674,7 @@ def build_model(**kwargs): | |
| kwargs["ncpu"] = ncpu | ||
| if torch.get_num_threads() != ncpu: | ||
| torch.set_num_threads(ncpu) | ||
| _limit_blas_threads(ncpu) | ||
|
|
||
| # build tokenizer | ||
| tokenizer = kwargs.get("tokenizer", None) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,218 @@ | ||
| """Regression cover for applying `ncpu` to BLAS as well as to torch. | ||
|
|
||
| Both settings are process-wide and follow `torch.set_num_threads` semantics. | ||
| The helper is driven directly for the update rule, and through the real | ||
| `AutoModel.build_model` path for the interaction between them -- swapping the | ||
| BLAS limiter touches torch's OpenMP pool as a side effect, which only shows up | ||
| across a sequence of builds. | ||
| """ | ||
|
|
||
| import numpy as np | ||
| import pytest | ||
| import scipy.linalg | ||
| import torch | ||
| import threadpoolctl | ||
|
|
||
| from funasr.auto.auto_model import ( | ||
| _current_blas_threads, | ||
| _limit_blas_threads, | ||
| ) | ||
|
|
||
|
|
||
| @pytest.fixture(autouse=True) | ||
| def _restore_thread_settings(): | ||
| """Leave both thread settings where the test found them. | ||
|
|
||
| The BLAS limit is deliberately process-wide and never released, so without | ||
| this a test would leak its thread count into every later test -- and the | ||
| torch count needs restoring for the same reason, since tests here set it | ||
| to make their assertions readable. | ||
| """ | ||
| original_blas = _current_blas_threads() | ||
| original_torch = torch.get_num_threads() | ||
| yield | ||
| if original_blas is not None: | ||
| _limit_blas_threads(original_blas) | ||
| torch.set_num_threads(original_torch) | ||
|
|
||
|
|
||
| def _require_blas_pools(): | ||
| if _current_blas_threads() is None: | ||
| pytest.skip("no BLAS pool is loaded in this environment") | ||
|
|
||
|
|
||
| class _ProbeModel(torch.nn.Module): | ||
| """Smallest thing `build_model` accepts, so no weights are downloaded.""" | ||
|
|
||
| def __init__(self, **kwargs): | ||
| super().__init__() | ||
| self.linear = torch.nn.Linear(2, 2) | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def probe_model(): | ||
| from funasr.register import tables | ||
|
|
||
| name = "ncpu-blas-probe" | ||
| added = name not in tables.model_classes | ||
| if added: | ||
| tables.model_classes[name] = _ProbeModel | ||
| yield name | ||
| if added: | ||
| tables.model_classes.pop(name, None) | ||
|
|
||
|
|
||
| def _build(probe_model, ncpu): | ||
| from funasr.auto.auto_model import AutoModel | ||
|
|
||
| AutoModel( | ||
| model=probe_model, | ||
| model_conf={}, | ||
| device="cpu", | ||
| ncpu=ncpu, | ||
| disable_update=True, | ||
| ) | ||
| return torch.get_num_threads(), _current_blas_threads() | ||
|
|
||
|
|
||
| def test_build_model_sets_both_settings_in_both_orders(probe_model): | ||
| """End-to-end through `build_model`, where the settings are applied. | ||
|
|
||
| Replacing the BLAS limiter drops the previous `threadpool_limits`, and that | ||
| object restores the OpenMP pools as well -- so without care the swap rolls | ||
| torch's thread count back to its value at the previous build, undoing the | ||
| `torch.set_num_threads(ncpu)` on the line above. Asserting only on the | ||
| helper misses this; a sequence of builds is what surfaces it. | ||
| """ | ||
| _require_blas_pools() | ||
|
|
||
| for order in ((8, 1), (1, 8), (4, 2, 7)): | ||
| for ncpu in order: | ||
| torch_threads, blas_threads = _build(probe_model, ncpu) | ||
| assert blas_threads == ncpu, ( | ||
| "order %s: BLAS settled on %s at ncpu=%s" | ||
| % (order, blas_threads, ncpu) | ||
| ) | ||
| assert torch_threads == ncpu, ( | ||
| "order %s: torch settled on %s at ncpu=%s" | ||
| % (order, torch_threads, ncpu) | ||
| ) | ||
|
|
||
|
|
||
| def test_build_model_without_speaker_clustering(probe_model): | ||
| """`ncpu` reaches BLAS whether or not clustering is configured.""" | ||
| _require_blas_pools() | ||
|
|
||
| torch_threads, blas_threads = _build(probe_model, 3) | ||
| assert blas_threads == 3 | ||
| assert torch_threads == 3 | ||
|
|
||
|
|
||
| def test_latest_ncpu_wins(): | ||
| """Later construction must be able to lower the limit. | ||
|
|
||
| The first revision returned early once a limit existed, so ncpu=1 after | ||
| ncpu=8 left BLAS at 8 -- exactly the case this change exists to fix. | ||
| """ | ||
| _require_blas_pools() | ||
|
|
||
| _limit_blas_threads(8) | ||
| assert _current_blas_threads() == 8 | ||
|
|
||
| _limit_blas_threads(1) | ||
| assert _current_blas_threads() == 1, ( | ||
| "a later, lower ncpu must take effect" | ||
| ) | ||
|
|
||
|
|
||
| def test_latest_ncpu_wins_in_both_orders(): | ||
| _require_blas_pools() | ||
|
|
||
| for first, second in ((8, 1), (1, 8), (4, 2), (2, 4)): | ||
| _limit_blas_threads(first) | ||
| assert _current_blas_threads() == first | ||
| _limit_blas_threads(second) | ||
| assert _current_blas_threads() == second, ( | ||
| "order %s -> %s did not settle on the newest value" | ||
| % (first, second) | ||
| ) | ||
|
|
||
|
|
||
| def test_repeated_same_value_is_stable(): | ||
| """Re-setting the current value must be a no-op, not a nested limit.""" | ||
| _require_blas_pools() | ||
|
|
||
| _limit_blas_threads(3) | ||
| for _ in range(4): | ||
| _limit_blas_threads(3) | ||
| assert _current_blas_threads() == 3 | ||
|
|
||
|
|
||
| def test_limit_is_process_wide_and_cross_thread(): | ||
| """BLAS pools are process-global, so a worker thread sees the cap too. | ||
|
|
||
| This is the property that makes the fix work at all: the clustering call | ||
| runs on a request worker, not on the thread that built the model. | ||
| """ | ||
| _require_blas_pools() | ||
|
|
||
| import threading | ||
|
|
||
| _limit_blas_threads(2) | ||
| seen = [] | ||
|
|
||
| def worker(): | ||
| seen.append( | ||
| sorted( | ||
| { | ||
| pool["num_threads"] | ||
| for pool in threadpoolctl.threadpool_info() | ||
| if pool["user_api"] == "blas" | ||
| } | ||
| ) | ||
| ) | ||
|
|
||
| thread = threading.Thread(target=worker) | ||
| thread.start() | ||
| thread.join() | ||
|
|
||
| assert seen == [[2]], "a worker thread did not observe the cap: %s" % seen | ||
|
|
||
|
|
||
| def test_the_cap_actually_reduces_blas_work(): | ||
| """The point of the change: fewer threads must mean less CPU. | ||
|
|
||
| A 357-row Laplacian is the size a 35-minute recording produces. | ||
| """ | ||
| _require_blas_pools() | ||
|
|
||
| import os | ||
|
|
||
| clock = os.sysconf("SC_CLK_TCK") | ||
|
|
||
| def process_cpu_seconds(): | ||
| fields = open("/proc/self/stat").read().split() | ||
| return (int(fields[13]) + int(fields[14])) / clock | ||
|
|
||
| rng = np.random.RandomState(0) | ||
| laplacian = rng.rand(357, 357) | ||
| laplacian = laplacian + laplacian.T | ||
|
|
||
| def measure(): | ||
| before = process_cpu_seconds() | ||
| scipy.linalg.eigh(laplacian) | ||
| return process_cpu_seconds() - before | ||
|
|
||
| threads = os.cpu_count() or 1 | ||
| if threads < 4: | ||
| pytest.skip("needs at least 4 cores to show a difference") | ||
|
|
||
| _limit_blas_threads(1) | ||
| one_thread_cpu = measure() | ||
| _limit_blas_threads(threads) | ||
| many_thread_cpu = measure() | ||
|
|
||
| assert many_thread_cpu > one_thread_cpu, ( | ||
| "capping BLAS did not reduce CPU: %d threads=%.3f core-s, " | ||
| "1 thread=%.3f core-s" % (threads, many_thread_cpu, one_thread_cpu) | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] Do not silently retain the first model's BLAS limit when later ncpu changes. Two actual build_model calls with explicit model_conf and a CPU Linear registry model, first ncpu=8 then ncpu=1, resolve the second config and torch thread count to 1 but leave both loaded OpenBLAS pools at 8. A separate observer thread also sees 8. Reversing the order leaves BLAS at 1 while the second torch/config value is 8. This early return makes the documented CPU limit depend on whichever model was constructed first, so lowering ncpu cannot bound the BLAS work this PR targets. Define and implement consistent process-wide update/conflict semantics rather than silently ignoring the requested value, and test both construction orders (including an initial model without speaker clustering).