Skip to content

Commit 64b96af

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. ghstack-source-id: 450820756 Differential Revision: [D124736179](https://our.internmc.facebook.com/intern/diff/D124736179/)
1 parent 4da9766 commit 64b96af

2 files changed

Lines changed: 372 additions & 4 deletions

File tree

‎backends/cuda/tests/test_int4_quantized_gemm.py‎

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

19+
import concurrent.futures
1920
import contextlib
21+
import os
22+
import pickle
2023
import statistics
24+
import tempfile
2125
import types
2226
import unittest
2327
from unittest import mock
@@ -192,6 +196,171 @@ def recording_pool():
192196
self.assertLessEqual(self._compiled(bucket) - before, counts["pooled"])
193197
_check_close(self, out, _dequant_matmul(x, *weights, GROUP_SIZE))
194198

199+
def _cache_hits(self, bucket: int):
200+
"""Cache hits and misses of the bucket's autotuned kernel in this
201+
process (its helper kernels, such as the split-K reduce, are not
202+
autotuned and compile here as before)."""
203+
name = int4_kernel._BUCKET_KERNELS[bucket].fn.fn.__name__
204+
hits = {"hit": 0, "miss": 0}
205+
206+
def listener(src, metadata, metadata_group, times, cache_hit):
207+
if src.name == name:
208+
hits["hit" if cache_hit else "miss"] += 1
209+
210+
return hits, mock.patch.object(triton.knobs.compilation, "listener", listener)
211+
212+
@unittest.skipIf(torch.version.hip is not None, "worker compiles are CUDA-only")
213+
def test_configs_compile_in_worker_processes(self) -> None:
214+
# A shape no other test tunes; with an empty Triton cache, every config
215+
# this process loads is one a worker compiled into the shared cache.
216+
n, k, bucket = 448, 1792, 2
217+
weights = _packed(n, k, seed=13)
218+
x = torch.randn(bucket, k, dtype=torch.bfloat16, device="cuda")
219+
hits, patch = self._cache_hits(bucket)
220+
with tempfile.TemporaryDirectory() as cache_dir, triton.knobs.cache.scope(), (
221+
patch
222+
):
223+
triton.knobs.cache.dir = cache_dir
224+
out = int4_kernel._launch(bucket, x, *weights, GROUP_SIZE)
225+
self.assertIsNotNone(quantized_gemm_family._compile_process_pool())
226+
self.assertGreater(hits["hit"], 1)
227+
self.assertEqual(hits["miss"], 0)
228+
_check_close(self, out, _dequant_matmul(x, *weights, GROUP_SIZE))
229+
230+
@unittest.skipIf(torch.version.hip is not None, "worker compiles are CUDA-only")
231+
def test_workers_that_cannot_import_the_kernel_fall_back_to_this_process(
232+
self,
233+
) -> None:
234+
n, k, bucket = 448, 2816, 2
235+
weights = _packed(n, k, seed=17)
236+
x = torch.randn(bucket, k, dtype=torch.bfloat16, device="cuda")
237+
request = quantized_gemm_family._process_compile_request
238+
239+
def unimportable(compile_fn):
240+
args = request(compile_fn)
241+
return None if args is None else (args[0], "no_such_module", *args[2:])
242+
243+
hits, patch = self._cache_hits(bucket)
244+
# An empty Triton cache: what this process compiles shows up as misses.
245+
with tempfile.TemporaryDirectory() as cache_dir, triton.knobs.cache.scope(), mock.patch.object(
246+
quantized_gemm_family, "_process_compile_request", unimportable
247+
), mock.patch.object(
248+
quantized_gemm_family, "_process_pool_failed", False
249+
), mock.patch.object(
250+
quantized_gemm_family, "_process_pool", None
251+
), patch:
252+
triton.knobs.cache.dir = cache_dir
253+
out = int4_kernel._launch(bucket, x, *weights, GROUP_SIZE)
254+
self.assertTrue(quantized_gemm_family._process_pool_failed)
255+
# Compiled here instead, and still correct.
256+
self.assertGreater(hits["miss"], 0)
257+
_check_close(self, out, _dequant_matmul(x, *weights, GROUP_SIZE))
258+
259+
def test_only_unusable_workers_stop_process_compiles(self) -> None:
260+
from torch._inductor.compile_worker.subproc_pool import SubprocException
261+
262+
failed = quantized_gemm_family._workers_failed
263+
# One config failing to compile in a worker keeps the workers.
264+
self.assertFalse(failed(error=SubprocException("ptxas error")))
265+
self.assertFalse(failed(error=pickle.PicklingError("local object")))
266+
self.assertFalse(failed(result=None))
267+
# The kernel cannot be imported there, or the pool closed or broke.
268+
self.assertTrue(failed(result=quantized_gemm_family._UNIMPORTABLE))
269+
self.assertTrue(failed(error=RuntimeError("SubprocPool closed")))
270+
self.assertTrue(
271+
failed(error=concurrent.futures.process.BrokenProcessPool("died"))
272+
)
273+
274+
def test_a_worker_applies_the_triton_environment_of_its_request(self) -> None:
275+
with mock.patch.dict(
276+
os.environ, {"TRITON_STALE": "1"}
277+
), triton.knobs.cache.scope():
278+
result = quantized_gemm_family._compile_in_process(
279+
tempfile.gettempdir(),
280+
"no_such_module",
281+
"kernel",
282+
"no_such_module",
283+
"Source",
284+
{},
285+
{},
286+
{},
287+
None,
288+
{},
289+
{},
290+
{"TRITON_DISABLE_LINE_INFO": "1"},
291+
)
292+
self.assertEqual(os.environ["TRITON_DISABLE_LINE_INFO"], "1")
293+
self.assertNotIn("TRITON_STALE", os.environ)
294+
self.assertEqual(result, quantized_gemm_family._UNIMPORTABLE)
295+
296+
def test_a_pool_that_cannot_start_stops_process_compiles(self) -> None:
297+
n, k, bucket = 448, 3072, 2
298+
weights = _packed(n, k, seed=31)
299+
x = torch.randn(bucket, k, dtype=torch.bfloat16, device="cuda")
300+
with mock.patch(
301+
"torch._inductor.compile_worker.subproc_pool.SubprocPool",
302+
side_effect=OSError("out of file descriptors"),
303+
), mock.patch.object(
304+
quantized_gemm_family, "_process_pool", None
305+
), mock.patch.object(
306+
quantized_gemm_family, "_process_pool_failed", False
307+
):
308+
out = int4_kernel._launch(bucket, x, *weights, GROUP_SIZE)
309+
self.assertTrue(quantized_gemm_family._process_pool_failed)
310+
_check_close(self, out, _dequant_matmul(x, *weights, GROUP_SIZE))
311+
312+
def _through_pool(self, error: BaseException):
313+
future = concurrent.futures.Future()
314+
future.set_exception(error)
315+
pool = mock.Mock()
316+
pool.submit.return_value = future
317+
request = (
318+
"cache",
319+
"module",
320+
"qualname",
321+
"module",
322+
"Source",
323+
{},
324+
{},
325+
{},
326+
None,
327+
{},
328+
{},
329+
{},
330+
)
331+
compile_fn = mock.Mock()
332+
with mock.patch.object(
333+
quantized_gemm_family, "_process_compile_request", return_value=request
334+
), mock.patch.object(
335+
quantized_gemm_family, "_compile_process_pool", return_value=pool
336+
), mock.patch.object(
337+
quantized_gemm_family, "_process_pool_failed", False
338+
), mock.patch.object(
339+
quantized_gemm_family, "_process_pool", None
340+
):
341+
quantized_gemm_family._compiled_in_a_process_first(compile_fn)()
342+
failed = quantized_gemm_family._process_pool_failed
343+
compile_fn.assert_called_once_with()
344+
return failed
345+
346+
def test_a_config_failing_in_a_worker_keeps_the_pool(self) -> None:
347+
from torch._inductor.compile_worker.subproc_pool import SubprocException
348+
349+
self.assertFalse(self._through_pool(SubprocException("ptxas error")))
350+
351+
def test_a_closed_pool_stops_process_compiles(self) -> None:
352+
self.assertTrue(self._through_pool(RuntimeError("SubprocPool closed")))
353+
354+
def test_submit_forwards_the_arguments_triton_passes(self) -> None:
355+
mode = object.__new__(quantized_gemm_family._ProcessCompileMode)
356+
base = quantized_gemm_family._async_compile.AsyncCompileMode
357+
with mock.patch.object(base, "submit", return_value="future") as submit:
358+
self.assertEqual(
359+
mode.submit("key", lambda: None, "finalize", "cleanup"), "future"
360+
)
361+
self.assertEqual(submit.call_args.args[0], "key")
362+
self.assertEqual(submit.call_args.args[2:], ("finalize", "cleanup"))
363+
195364
def test_fake_tensors_are_not_precompiled(self) -> None:
196365
# A full launch of a shape no other test tunes, so only the fake-tensor
197366
# check can keep it off the pool: the same launch on real tensors uses it.

0 commit comments

Comments
 (0)