Skip to content

Commit 4fa6c89

Browse files
committed
[executorch][cuda] Compile quantized GEMM autotune configs in worker processes
Pull Request resolved: #23787 D124355785 compiles an autotuned quantized GEMM's configs on a thread pool before Triton benchmarks them. In one process, though, Triton's MLIR and LLVM passes (`make_ttgir`, `make_llir`, `make_ptx`) hold the GIL: during a bucket-64 shape only ~5 of the 32 compile threads were busy on average, and the export's main thread spent ~21 minutes waiting on that pool. Each async compile Triton submits to the pool now first runs the same `triton.compile(source, target, options, env_vars)` in a worker process, writing the kernel into the shared Triton cache; Triton's own compile call then runs as before in the parent and loads it from the cache (same source, options and cache key, so the same kernel). The workers are Inductor's `SubprocPool`: a fresh Python process that forks them, so they neither re-run the caller's main module (as `spawn` would) nor inherit its CUDA state (as `fork` would). They are started on first use and kept for later shapes, set `CUDA_VISIBLE_DEVICES=""` (compiling needs no GPU) and use the parent's Triton cache directory. If the workers cannot import the kernel (e.g. a binary without it) or die, compiles go back to the parent process for good, with a warning; a config that fails to compile in a worker is compiled in the parent too, where the benchmark skips it as before. The search, the timing and the picks are unchanged. One INT4 W4A16 bucket-64 shape (5376 x 4096), empty Triton cache, A100: 58.0 s -> 43.7 s, including the workers' start; 289 of the autotuned kernels the parent loads are cache hits from the workers. Full Muse Glimmer 30B batching export (`export_solo_batching.py`, 10 methods; A100; empty Triton and Inductor caches), stacked on D124713681 (min): | | before | this diff | |---|---|---| | main thread waiting on the compile pool | 20.9 | 12.2 | | AOTI forward_16 / forward_32 / forward_64 | 11.3 / 13.2 / 17.0 | 9.8 / 11.6 / 15.5 | | total | 125.7 | 123.3 | The pool wait drops by 8.7 min, but the end-to-end gain was 2.4 min in this run: other phases (Inductor's own compile wait, lowering, the first method) took a few minutes longer, while the rest of the host was ~3 cores busier than during the run before. The weight file is byte-identical, and the export generates the same tokens (greedy; 512 and 4 x 2k prompts) at the same speed (63.2 vs 62.5 tok/s; 110.4 vs 110.0). The quantized GEMM test suites, which compile many configs cold, also run faster: INT5/INT6/INT8 (+ CUDA graph timing) 30:05 -> 6:47, INT4/family/dispatch/launch params 26:40 -> 18:26 in OSS. Differential Revision: [D124736179](https://our.internmc.facebook.com/intern/diff/D124736179/) ghstack-source-id: 447900648
1 parent c2ecdb5 commit 4fa6c89

2 files changed

Lines changed: 243 additions & 4 deletions

File tree

‎backends/cuda/tests/test_int4_quantized_gemm.py‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
python -m pytest backends/cuda/tests/test_int4_quantized_gemm.py -v
1717
"""
1818

19+
import concurrent.futures
1920
import statistics
21+
import tempfile
2022
import unittest
2123
from unittest import mock
2224

@@ -183,6 +185,78 @@ def counting_pool():
183185
self.assertFalse(quantized_gemm_family._COMPILED_AHEAD)
184186
_check_close(self, out, _dequant_matmul(x, *weights, GROUP_SIZE))
185187

188+
def _cache_hits(self, bucket: int):
189+
"""Cache hits and misses of the bucket's autotuned kernel in this
190+
process (its helper kernels, such as the split-K reduce, are not
191+
autotuned and compile here as before)."""
192+
name = int4_kernel._BUCKET_KERNELS[bucket].fn.fn.__name__
193+
hits = {"hit": 0, "miss": 0}
194+
195+
def listener(src, metadata, metadata_group, times, cache_hit):
196+
if src.name == name:
197+
hits["hit" if cache_hit else "miss"] += 1
198+
199+
return hits, mock.patch.object(triton.knobs.compilation, "listener", listener)
200+
201+
def test_configs_compile_in_worker_processes(self) -> None:
202+
# A shape no other test tunes; with an empty Triton cache, every config
203+
# this process loads is one a worker compiled into the shared cache.
204+
n, k, bucket = 448, 1792, 2
205+
weights = _packed(n, k, seed=13)
206+
x = torch.randn(bucket, k, dtype=torch.bfloat16, device="cuda")
207+
hits, patch = self._cache_hits(bucket)
208+
with tempfile.TemporaryDirectory() as cache_dir, mock.patch.object(
209+
triton.knobs.cache, "dir", cache_dir
210+
), patch:
211+
out = int4_kernel._launch(bucket, x, *weights, GROUP_SIZE)
212+
self.assertIsNotNone(quantized_gemm_family._compile_process_pool())
213+
self.assertGreater(hits["hit"], 1)
214+
self.assertEqual(hits["miss"], 0)
215+
_check_close(self, out, _dequant_matmul(x, *weights, GROUP_SIZE))
216+
217+
def test_workers_that_cannot_import_the_kernel_fall_back_to_this_process(
218+
self,
219+
) -> None:
220+
n, k, bucket = 448, 2816, 2
221+
weights = _packed(n, k, seed=17)
222+
x = torch.randn(bucket, k, dtype=torch.bfloat16, device="cuda")
223+
request = quantized_gemm_family._process_compile_request
224+
225+
def unimportable(compile_fn):
226+
args = request(compile_fn)
227+
return None if args is None else (args[0], "no_such_module", *args[2:])
228+
229+
hits, patch = self._cache_hits(bucket)
230+
# An empty Triton cache: what this process compiles shows up as misses.
231+
with tempfile.TemporaryDirectory() as cache_dir, mock.patch.object(
232+
triton.knobs.cache, "dir", cache_dir
233+
), mock.patch.object(
234+
quantized_gemm_family, "_process_compile_request", unimportable
235+
), mock.patch.object(
236+
quantized_gemm_family, "_process_pool_failed", False
237+
), mock.patch.object(
238+
quantized_gemm_family, "_process_pool", None
239+
), patch:
240+
out = int4_kernel._launch(bucket, x, *weights, GROUP_SIZE)
241+
self.assertTrue(quantized_gemm_family._process_pool_failed)
242+
# Compiled here instead, and still correct.
243+
self.assertGreater(hits["miss"], 0)
244+
_check_close(self, out, _dequant_matmul(x, *weights, GROUP_SIZE))
245+
246+
def test_only_unusable_workers_stop_process_compiles(self) -> None:
247+
from torch._inductor.compile_worker.subproc_pool import SubprocException
248+
249+
failed = quantized_gemm_family._workers_failed
250+
# One config failing to compile in a worker keeps the workers.
251+
self.assertFalse(failed(error=SubprocException("ptxas error")))
252+
self.assertFalse(failed(result=None))
253+
# The kernel cannot be imported there, or the pool closed or broke.
254+
self.assertTrue(failed(result=quantized_gemm_family._UNIMPORTABLE))
255+
self.assertTrue(failed(error=RuntimeError("SubprocPool closed")))
256+
self.assertTrue(
257+
failed(error=concurrent.futures.process.BrokenProcessPool("died"))
258+
)
259+
186260
def test_fake_tensors_are_not_precompiled(self) -> None:
187261
# A full launch of a shape no other test tunes, so only the fake-tensor
188262
# check can keep it off the pool: the same launch on real tensors uses it.

‎backends/cuda/triton/kernels/quantized_gemm_family.py‎

Lines changed: 169 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import concurrent.futures
2828
import contextlib
2929
import contextvars
30+
import importlib
3031
import inspect
3132
import logging
3233
import os
@@ -47,8 +48,9 @@
4748
): # A Triton without parallel compile compiles each config on first use.
4849
_async_compile = None
4950

50-
# Threads that compile one kernel's autotune configs. Compiling is mostly ptxas
51-
# and LLVM, which run outside the GIL.
51+
# Concurrent compiles of autotune configs. Each runs in a worker process: in
52+
# one process Triton's MLIR and LLVM passes hold the GIL, so threads alone keep
53+
# only a few compiles busy.
5254
_MAX_COMPILE_THREADS = 32
5355

5456
logger: logging.Logger = logging.getLogger(__name__)
@@ -181,6 +183,169 @@ def _has_untuned_configs(kernel, args: Sequence, kwargs: dict) -> bool:
181183
)
182184

183185

186+
def _compile_workers() -> int:
187+
return min(_MAX_COMPILE_THREADS, os.cpu_count() or 1)
188+
189+
190+
_PROCESS_POOL_LOCK = threading.Lock()
191+
_process_pool = None
192+
_process_pool_failed = False
193+
194+
195+
def _compile_process_pool():
196+
"""The worker processes, started on first use and kept for later shapes;
197+
None once they have failed. Inductor's ``SubprocPool``: a fresh Python
198+
process that forks the workers, so they neither re-run the caller's main
199+
module (as ``spawn`` would) nor inherit its CUDA state (as ``fork`` would)."""
200+
global _process_pool
201+
with _PROCESS_POOL_LOCK:
202+
if _process_pool is None and not _process_pool_failed:
203+
from torch._inductor.compile_worker.subproc_pool import (
204+
SubprocKind,
205+
SubprocPool,
206+
)
207+
208+
_process_pool = SubprocPool(_compile_workers(), kind=SubprocKind.FORK)
209+
return _process_pool
210+
211+
212+
def _stop_using_processes(error: BaseException) -> None:
213+
global _process_pool, _process_pool_failed
214+
with _PROCESS_POOL_LOCK:
215+
if _process_pool_failed:
216+
return
217+
_process_pool_failed = True
218+
pool, _process_pool = _process_pool, None
219+
logger.warning(
220+
"Compiling quantized GEMM configs in this process from now on: worker "
221+
"processes failed (%s)",
222+
error,
223+
)
224+
if pool is not None:
225+
pool.shutdown()
226+
227+
228+
def _compile_in_process(
229+
cache_dir: str,
230+
module: str,
231+
qualname: str,
232+
source_module: str,
233+
source_qualname: str,
234+
signature: dict,
235+
constants: dict,
236+
attrs: dict,
237+
target,
238+
options: dict,
239+
env_vars: dict,
240+
) -> None:
241+
"""Compiles one config in a worker process into the shared Triton cache.
242+
Returns ``_UNIMPORTABLE`` if the worker cannot find the kernel or source
243+
class, which no other config would find either."""
244+
# Compiling needs no GPU; keep the worker from initializing one.
245+
os.environ["CUDA_VISIBLE_DEVICES"] = ""
246+
triton.knobs.cache.dir = cache_dir
247+
try:
248+
fn = importlib.import_module(module)
249+
for part in qualname.split("."):
250+
fn = getattr(fn, part)
251+
while not isinstance(fn, triton.runtime.JITFunction):
252+
fn = fn.fn
253+
source = importlib.import_module(source_module)
254+
for part in source_qualname.split("."):
255+
source = getattr(source, part)
256+
except (ImportError, AttributeError):
257+
return _UNIMPORTABLE
258+
triton.compile(
259+
source(fn, signature, constants, attrs),
260+
target=target,
261+
options=options,
262+
_env_vars=env_vars,
263+
)
264+
return None
265+
266+
267+
def _process_compile_request(compile_fn) -> Optional[tuple]:
268+
"""The arguments of the ``triton.compile`` call that Triton's async-compile
269+
closure makes, so a worker process can make the same call. None when the
270+
closure or kernel is not the expected shape (it then compiles in-process)."""
271+
names = compile_fn.__code__.co_freevars
272+
if set(names) != {"env_vars", "options", "self", "src", "target"}:
273+
return None
274+
cells = dict(zip(names, (cell.cell_contents for cell in compile_fn.__closure__)))
275+
src = cells["src"]
276+
jit_fn = getattr(src, "fn", None)
277+
module = getattr(jit_fn, "module", None)
278+
qualname = getattr(getattr(jit_fn, "fn", None), "__qualname__", "")
279+
if module is None or not qualname or "<locals>" in qualname:
280+
return None
281+
return (
282+
triton.knobs.cache.dir,
283+
module,
284+
qualname,
285+
type(src).__module__,
286+
type(src).__qualname__,
287+
src.signature,
288+
src.constants,
289+
src.attrs,
290+
cells["target"],
291+
cells["options"].__dict__,
292+
cells["env_vars"],
293+
)
294+
295+
296+
_UNIMPORTABLE = "unimportable"
297+
298+
299+
def _workers_failed(result=None, error: Optional[BaseException] = None) -> bool:
300+
"""Whether a worker's answer means the workers themselves are unusable, as
301+
opposed to one config failing to compile (which ``SubprocPool`` reports as
302+
a ``SubprocException``): they cannot import the kernel, or the pool closed
303+
or broke."""
304+
from torch._inductor.compile_worker.subproc_pool import SubprocException
305+
306+
if error is not None:
307+
return not isinstance(error, SubprocException)
308+
return result == _UNIMPORTABLE
309+
310+
311+
def _compiled_in_a_process_first(compile_fn):
312+
request = _process_compile_request(compile_fn)
313+
if request is None:
314+
return compile_fn
315+
316+
def compile_then_load():
317+
pool = _compile_process_pool()
318+
if pool is not None:
319+
try:
320+
result = pool.submit(_compile_in_process, *request).result()
321+
except Exception as error:
322+
if _workers_failed(error=error):
323+
_stop_using_processes(error)
324+
# Otherwise this config also fails to compile in-process, where
325+
# the benchmark skips it as before.
326+
else:
327+
if _workers_failed(result=result):
328+
_stop_using_processes(
329+
RuntimeError(f"workers cannot import {request[1]}")
330+
)
331+
# Loads what the worker cached (same source, options and key), or
332+
# compiles here if it did not.
333+
return compile_fn()
334+
335+
return compile_then_load
336+
337+
338+
if _async_compile is not None:
339+
340+
class _ProcessCompileMode(_async_compile.AsyncCompileMode):
341+
"""Triton's async compile, with each compile done in a worker process."""
342+
343+
def submit(self, key, compile_fn, finalize_fn):
344+
return super().submit(
345+
key, _compiled_in_a_process_first(compile_fn), finalize_fn
346+
)
347+
348+
184349
_COMPILE_THREADS_LOCK = threading.Lock()
185350
_compile_thread_pool: Optional[concurrent.futures.ThreadPoolExecutor] = None
186351

@@ -191,15 +356,15 @@ def _compile_threads() -> concurrent.futures.ThreadPoolExecutor:
191356
with _COMPILE_THREADS_LOCK:
192357
if _compile_thread_pool is None:
193358
_compile_thread_pool = concurrent.futures.ThreadPoolExecutor(
194-
min(_MAX_COMPILE_THREADS, os.cpu_count() or 1),
359+
_compile_workers(),
195360
thread_name_prefix="quantized_gemm_compile",
196361
)
197362
return _compile_thread_pool
198363

199364

200365
@contextlib.contextmanager
201366
def _compile_pool():
202-
with _async_compile.AsyncCompileMode(_compile_threads(), ignore_errors=True):
367+
with _ProcessCompileMode(_compile_threads(), ignore_errors=True):
203368
yield
204369

205370

0 commit comments

Comments
 (0)