Skip to content

Commit a7f9025

Browse files
committed
Implement batching for TTG device tasks
The task can call `coawait ttg::device::coop(...)` to suspend and get collected into batches, capturing the arguments passed to `coop()`. One task will become the leader and will collect the arguments and pass them into a batched task. Attached to the task is a callback to screen whether a key is eligible for batching together with other keys. When attaching the callback we can provide an upper limit for the number of batched tasks. Signed-off-by: Joseph Schuchart <joseph.schuchart@stonybrook.edu>
1 parent fc6f506 commit a7f9025

9 files changed

Lines changed: 647 additions & 69 deletions

File tree

CMakeLists.txt

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,51 @@ include(FindOrFetchPARSEC)
217217
if (TARGET PaRSEC::parsec)
218218
message(STATUS "PARSEC_FOUND=1")
219219
endif(TARGET PaRSEC::parsec)
220+
221+
# whether the linked PaRSEC supports GPU kernel batching
222+
# (parsec_gpu_task_collect_batch / PARSEC_DEV_CHORE_ALLOW_BATCH); older
223+
# PaRSEC checkouts lack this API entirely, so probe for it rather than
224+
# assuming its presence. Use CMAKE_REQUIRED_INCLUDES with PaRSEC's plain
225+
# source/build directories rather than CMAKE_REQUIRED_LIBRARIES with the
226+
# PaRSEC::parsec target: that target's INTERFACE_INCLUDE_DIRECTORIES is a
227+
# generator-expression-laden list (BUILD_INTERFACE/INSTALL_INTERFACE/
228+
# conditional wrappers) that try_compile does not reliably resolve. Note:
229+
# TTG_HAVE_PARSEC_DEV_BATCH must NOT be set before check_cxx_source_compiles
230+
# runs -- check_xxx macros treat an already-DEFINED result variable as a
231+
# cached prior result and silently skip re-running the check, which was
232+
# quietly defeating this probe (it kept "detecting" whatever it was
233+
# pre-set to, without ever invoking the compiler).
234+
if (TARGET PaRSEC::parsec AND TTG_HAVE_DEVICE)
235+
include(CheckCXXSourceCompiles)
236+
include(CMakePushCheckState)
237+
# PaRSEC's headers (parsec/datatype.h) include <mpi.h> transitively; find it
238+
# here (harmless/idempotent if found again later by the full MPI section).
239+
find_package(MPI QUIET COMPONENTS CXX)
240+
cmake_push_check_state()
241+
set(CMAKE_REQUIRED_INCLUDES
242+
${PARSEC_SOURCE_DIR} ${PARSEC_SOURCE_DIR}/parsec/include
243+
${PARSEC_BINARY_DIR} ${PARSEC_BINARY_DIR}/parsec/include
244+
${MPI_CXX_INCLUDE_DIRS})
245+
# Skip OpenMPI's deprecated C++ bindings (<mpi.h> pulls them in by default,
246+
# requiring extra libraries to link a trivial try_compile executable); we
247+
# never call any MPI function here, we just need the headers to parse.
248+
set(CMAKE_REQUIRED_DEFINITIONS -DOMPI_SKIP_MPICXX -DMPICH_SKIP_MPICXX)
249+
check_cxx_source_compiles("
250+
#include <parsec.h>
251+
#include <parsec/mca/device/device.h>
252+
#include <parsec/mca/device/device_gpu.h>
253+
int main() {
254+
(void)&parsec_gpu_task_collect_batch;
255+
(void)&parsec_mca_device_type_supports_batch;
256+
unsigned f = PARSEC_DEV_CHORE_ALLOW_BATCH;
257+
(void)f;
258+
return 0;
259+
}
260+
" TTG_HAVE_PARSEC_DEV_BATCH)
261+
cmake_pop_check_state()
262+
else()
263+
set(TTG_HAVE_PARSEC_DEV_BATCH FALSE)
264+
endif()
220265
# MADNESS
221266
include(FindOrFetchMADNESS)
222267
if (TARGET MADworld)

cmake/ttg-config.cmake.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ set(TTG_HAVE_LEVEL_ZERO @TTG_HAVE_LEVEL_ZERO@)
2323
set(TTG_HAVE_DPCPP @TTG_HAVE_DPCPP@)
2424
set(TTG_HAVE_DEVICE @TTG_HAVE_DEVICE@)
2525
set(TTG_HAVE_COROUTINE @TTG_HAVE_COROUTINE@)
26+
set(TTG_HAVE_PARSEC_DEV_BATCH @TTG_HAVE_PARSEC_DEV_BATCH@)
2627

2728
# make TTG CMake modules discoverable + load AddTTGExecutable by default
2829
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/modules")

examples/potrf/potrf.h

Lines changed: 217 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// SPDX-License-Identifier: BSD-3-Clause
22
#pragma once
33

4+
#include <atomic>
45
#include <ttg.h>
56
#include <ttg/config.h>
67
#include "lapack.hh"
@@ -86,6 +87,87 @@ namespace potrf {
8687
hipblasDnrm2(hipblas_handle(), size, buffer, 1, norm);
8788
#endif
8889
}
90+
91+
#if defined(ENABLE_DEVICE_KERNEL)
92+
/**
93+
* Small round-robin pool of pinned host / device pointer-array triples used
94+
* to build a single cublasDgemmBatched/hipblasDgemmBatched call from the
95+
* ttg::device::batch collected via ttg::device::coop (see make_gemm_batched
96+
* below). `num_slots` is sized generously so that an in-flight batch -- in
97+
* practice bounded by the number of concurrent device streams -- does not
98+
* reuse a slot before its batched kernel has completed. This is
99+
* illustrative example code, not a general-purpose device allocator.
100+
* Works (as a degenerate size-1 batch) even if the linked PaRSEC lacks
101+
* kernel-batching support; no TTG_HAVE_PARSEC_DEV_BATCH guard is needed.
102+
*/
103+
template <typename T>
104+
struct GemmBatchPointerPool {
105+
explicit GemmBatchPointerPool(std::size_t max_batch_size, std::size_t num_slots = 8)
106+
: max_batch_size(max_batch_size), slots(num_slots) {
107+
for (auto &s : slots) {
108+
s.hostA.resize(max_batch_size);
109+
s.hostB.resize(max_batch_size);
110+
s.hostC.resize(max_batch_size);
111+
#if defined(TTG_ENABLE_CUDA)
112+
cudaHostRegister(s.hostA.data(), max_batch_size * sizeof(const T *), cudaHostRegisterDefault);
113+
cudaHostRegister(s.hostB.data(), max_batch_size * sizeof(const T *), cudaHostRegisterDefault);
114+
cudaHostRegister(s.hostC.data(), max_batch_size * sizeof(T *), cudaHostRegisterDefault);
115+
cudaMalloc(&s.devA, max_batch_size * sizeof(const T *));
116+
cudaMalloc(&s.devB, max_batch_size * sizeof(const T *));
117+
cudaMalloc(&s.devC, max_batch_size * sizeof(T *));
118+
#elif defined(TTG_ENABLE_HIP)
119+
hipHostRegister(s.hostA.data(), max_batch_size * sizeof(const T *), hipHostRegisterDefault);
120+
hipHostRegister(s.hostB.data(), max_batch_size * sizeof(const T *), hipHostRegisterDefault);
121+
hipHostRegister(s.hostC.data(), max_batch_size * sizeof(T *), hipHostRegisterDefault);
122+
hipMalloc(&s.devA, max_batch_size * sizeof(const T *));
123+
hipMalloc(&s.devB, max_batch_size * sizeof(const T *));
124+
hipMalloc(&s.devC, max_batch_size * sizeof(T *));
125+
#endif
126+
}
127+
}
128+
129+
~GemmBatchPointerPool() {
130+
for (auto &s : slots) {
131+
#if defined(TTG_ENABLE_CUDA)
132+
cudaHostUnregister(s.hostA.data());
133+
cudaHostUnregister(s.hostB.data());
134+
cudaHostUnregister(s.hostC.data());
135+
cudaFree(s.devA);
136+
cudaFree(s.devB);
137+
cudaFree(s.devC);
138+
#elif defined(TTG_ENABLE_HIP)
139+
hipHostUnregister(s.hostA.data());
140+
hipHostUnregister(s.hostB.data());
141+
hipHostUnregister(s.hostC.data());
142+
hipFree(s.devA);
143+
hipFree(s.devB);
144+
hipFree(s.devC);
145+
#endif
146+
}
147+
}
148+
149+
struct slot_t {
150+
std::vector<const T *> hostA, hostB;
151+
std::vector<T *> hostC;
152+
const T **devA = nullptr;
153+
const T **devB = nullptr;
154+
T **devC = nullptr;
155+
};
156+
157+
/* acquire the next slot (round-robin); the caller fills hostA/hostB/hostC
158+
* (up to `count` entries) with this batch's device pointers and then
159+
* copies them to devA/devB/devC (e.g. via cudaMemcpyAsync/hipMemcpyAsync
160+
* on the current stream) before launching the batched kernel. */
161+
slot_t &acquire() {
162+
std::size_t idx = next.fetch_add(1, std::memory_order_relaxed) % slots.size();
163+
return slots[idx];
164+
}
165+
166+
std::size_t max_batch_size;
167+
std::vector<slot_t> slots;
168+
std::atomic<std::size_t> next{0};
169+
};
170+
#endif // defined(ENABLE_DEVICE_KERNEL)
89171
#endif // ENABLE_DEVICE_KERNEL
90172

91173
template <typename MatrixT>
@@ -530,14 +612,9 @@ namespace potrf {
530612
assert(tile_nk.rows() == tile_mn.cols());
531613

532614
if (ttg::tracing()) ttg::print("GEMM(", key, ")");
533-
#if defined(DEBUG_TILES_VALUES) && 0
534-
//std::cout << "Before GEMM(" << key << "), A(" << M << ", " << K << ") is " << tile_mk << " and A(" << K << ", "
535-
// << N << ") is " << tile_nk << " and A(" << M << ", " << N << ") is " << tile_mn;
536-
#endif
537615

538616
#ifdef DEBUG_TILES_VALUES
539617
std::array<T, 4> norms; // input for tile_mk & tile_nk & tile_mn and output
540-
//auto norms_s = ttg::make_scratch(norms.data(), ttg::scope::Allocate, norms.size());
541618
co_await ttg::device::select(tile_mk.buffer(), tile_nk.buffer(), tile_mn.buffer());
542619

543620
/* compute the norms at input */
@@ -548,7 +625,6 @@ namespace potrf {
548625
co_await ttg::device::select(tile_mk.buffer(), tile_nk.buffer(), tile_mn.buffer());
549626
#endif // DEBUG_TILES_VALUES
550627

551-
int device = ttg::device::current_device();
552628
double alpha = -1.0;
553629
double beta = 1.0;
554630

@@ -570,7 +646,6 @@ namespace potrf {
570646
tile_mn.buffer().current_device_ptr(), tile_mn.lda());
571647
#endif
572648

573-
574649
#ifdef DEBUG_TILES_VALUES
575650
/* compute the norm at output */
576651
device_norm(tile_mn, &norms[3]);
@@ -639,6 +714,113 @@ namespace potrf {
639714
#endif // defined(ENABLE_DEVICE_KERNEL)
640715
}
641716

717+
#if defined(ENABLE_DEVICE_KERNEL)
718+
/**
719+
* Batching-enabled variant of make_gemm: collects the batch of sibling
720+
* GEMMs the runtime forms via ttg::device::coop/TT::set_batch_matcher and,
721+
* if this task is the batch's leader, submits ONE cublasDgemmBatched/
722+
* hipblasDgemmBatched call on behalf of every member. A batch of size 1
723+
* (e.g. because the linked PaRSEC lacks kernel-batching support, or no
724+
* siblings were ready) takes the exact same code path, so this is always
725+
* correct to use, just without the performance benefit in that case.
726+
* Does not support DEBUG_TILES_VALUES (see make_gemm for that).
727+
*/
728+
template <typename MatrixT>
729+
auto make_gemm_batched(MatrixT& A,
730+
ttg::Edge<Key3, MatrixTile<typename MatrixT::element_type>>& input_disp, // From the dispatcher
731+
ttg::Edge<Key3, MatrixTile<typename MatrixT::element_type>>& input_mk, // from TRSM
732+
ttg::Edge<Key3, MatrixTile<typename MatrixT::element_type>>& input_nk, // from TRSM
733+
ttg::Edge<Key3, MatrixTile<typename MatrixT::element_type>>& input_mn, // from TRSM
734+
ttg::Edge<Key2, MatrixTile<typename MatrixT::element_type>>& output_trsm, // to TRSM
735+
ttg::Edge<Key3, MatrixTile<typename MatrixT::element_type>>& output_gemm,
736+
std::size_t max_batch_size = 32) {
737+
using T = typename MatrixT::element_type;
738+
auto ptr_pool = std::make_shared<GemmBatchPointerPool<T>>(max_batch_size);
739+
auto f = [=](const Key3& key, const MatrixTile<T>& tile_mk, const MatrixTile<T>& tile_nk, MatrixTile<T>&& tile_mn,
740+
std::tuple<ttg::Out<Key2, MatrixTile<T>>, ttg::Out<Key3, MatrixTile<T>>>& out) TASKRET {
741+
const int M = key[0];
742+
const int N = key[1];
743+
const int K = key[2];
744+
assert(M != N && M > K && N > K);
745+
746+
assert(tile_mk.cols() == tile_nk.cols());
747+
assert(tile_mk.rows() == tile_mn.rows());
748+
assert(tile_nk.rows() == tile_mn.cols());
749+
750+
if (ttg::tracing()) ttg::print("GEMM(", key, ")");
751+
752+
co_await ttg::device::select(tile_mk.buffer(), tile_nk.buffer(), tile_mn.buffer());
753+
754+
/* Collect the batch of GEMMs the runtime formed together with this task
755+
* (see set_batch_matcher below) and, if we're the batch's leader, submit
756+
* ONE batched kernel on behalf of every member. */
757+
auto batch = co_await ttg::device::coop<Key3>(tile_mk, tile_nk, tile_mn);
758+
759+
if (batch.is_leader()) {
760+
const std::size_t nb = batch.size();
761+
auto &slot = ptr_pool->acquire();
762+
for (std::size_t i = 0; i < nb; ++i) {
763+
slot.hostA[i] = batch[i].template get<0>().buffer().current_device_ptr();
764+
slot.hostB[i] = batch[i].template get<1>().buffer().current_device_ptr();
765+
slot.hostC[i] = batch[i].template get<2>().buffer().current_device_ptr();
766+
}
767+
double alpha = -1.0;
768+
double beta = 1.0;
769+
int mb = tile_mk.rows(), nnb = tile_nk.rows(), kb = tile_nk.cols();
770+
#if defined(TTG_ENABLE_CUDA)
771+
cudaMemcpyAsync(slot.devA, slot.hostA.data(), nb * sizeof(const T *), cudaMemcpyHostToDevice,
772+
ttg::device::current_stream());
773+
cudaMemcpyAsync(slot.devB, slot.hostB.data(), nb * sizeof(const T *), cudaMemcpyHostToDevice,
774+
ttg::device::current_stream());
775+
cudaMemcpyAsync(slot.devC, slot.hostC.data(), nb * sizeof(T *), cudaMemcpyHostToDevice,
776+
ttg::device::current_stream());
777+
cublasDgemmBatched(cublas_handle(), CUBLAS_OP_N, CUBLAS_OP_T, mb, nnb, kb, &alpha,
778+
slot.devA, tile_mk.lda(), slot.devB, tile_nk.lda(), &beta,
779+
slot.devC, tile_mn.lda(), (int)nb);
780+
#elif defined(TTG_ENABLE_HIP)
781+
hipMemcpyAsync(slot.devA, slot.hostA.data(), nb * sizeof(const T *), hipMemcpyHostToDevice,
782+
ttg::device::current_stream());
783+
hipMemcpyAsync(slot.devB, slot.hostB.data(), nb * sizeof(const T *), hipMemcpyHostToDevice,
784+
ttg::device::current_stream());
785+
hipMemcpyAsync(slot.devC, slot.hostC.data(), nb * sizeof(T *), hipMemcpyHostToDevice,
786+
ttg::device::current_stream());
787+
hipblasDgemmBatched(hipblas_handle(), HIPBLAS_OP_N, HIPBLAS_OP_T, mb, nnb, kb, &alpha,
788+
slot.devA, tile_mk.lda(), slot.devB, tile_nk.lda(), &beta,
789+
slot.devC, tile_mn.lda(), (int)nb);
790+
#endif
791+
}
792+
// followers: the leader's batched call already covers our output tile.
793+
794+
// matching the non-batched GEMM, no explicit device::wait() is needed
795+
// here: nothing downstream needs this tile back on the host, so we
796+
// forward it directly (PaRSEC's own data-copy tracking, plus the fact
797+
// that the kernel and any subsequent transfers of this tile are
798+
// enqueued on the same device stream, take care of ordering).
799+
if (N == K + 1) {
800+
/* send the tile to trsm */
801+
if (ttg::tracing()) ttg::print("GEMM(", key, "): sending output to TRSM(", Key2{M, N}, ")");
802+
co_await ttg::device::send<0>(Key2(M, N), std::move(tile_mn), out);
803+
} else {
804+
/* send the tile to the next gemm */
805+
if (ttg::tracing()) ttg::print("GEMM(", key, "): sending output to GEMM(", Key3{M, N, K + 1}, ")");
806+
co_await ttg::device::send<1>(Key3(M, N, K + 1), std::move(tile_mn), out);
807+
}
808+
};
809+
auto tt_gemm = ttg::make_tt<ES>(f, ttg::edges(input_mk, input_nk, ttg::fuse(input_disp, input_mn)),
810+
ttg::edges(output_trsm, output_gemm), "GEMM", {"input_mk", "input_kn", "input_mn/dispatcher"},
811+
{"output_trsm", "outout_gemm"});
812+
// cublasDgemmBatched/hipblasDgemmBatched require identical (m,n,k) and
813+
// leading dimensions across the whole batch; tiles sharing the same K
814+
// column in this tiling always have matching geometry, so that is a
815+
// sufficient compatibility test. Unconditionally safe to call: it is a
816+
// no-op unless the linked PaRSEC actually supports kernel batching.
817+
tt_gemm->set_batch_matcher(
818+
[](const Key3& head, const Key3& cand) { return head[2] == cand[2]; },
819+
max_batch_size);
820+
return tt_gemm;
821+
}
822+
#endif // defined(ENABLE_DEVICE_KERNEL)
823+
642824
template <typename T>
643825
auto make_dispatcher(ttg::Edge<Key2, MatrixTile<T>>& input, ttg::Edge<Key1, MatrixTile<T>>& to_potrf,
644826
ttg::Edge<Key2, MatrixTile<T>>& to_trsm, ttg::Edge<Key2, MatrixTile<T>>& to_syrk,
@@ -679,7 +861,7 @@ namespace potrf {
679861
template <typename MatrixT>
680862
auto make_potrf_ttg(MatrixT& A, ttg::Edge<Key2, MatrixTile<typename MatrixT::element_type>>& input,
681863
ttg::Edge<Key2, MatrixTile<typename MatrixT::element_type>>& output, bool defer_write,
682-
bool enable_device_map = true) {
864+
bool enable_device_map = true, bool enable_gemm_batching = false) {
683865
using T = typename MatrixT::element_type;
684866
auto keymap1 = [&](const Key1& key) { return A.rank_of(key[0], key[0]); };
685867

@@ -744,15 +926,6 @@ namespace potrf {
744926
}
745927
#endif // 0
746928

747-
auto tt_gemm = make_gemm(A, disp_gemm, trsm_gemm_row, trsm_gemm_col, gemm_gemm, gemm_trsm, gemm_gemm);
748-
tt_gemm->set_keymap(keymap3);
749-
tt_gemm->set_defer_writer(defer_write);
750-
#ifdef ENABLE_DEVICE_KERNEL
751-
if (enable_device_map) {
752-
tt_gemm->set_devicemap(devmap3);
753-
}
754-
#endif // 0
755-
756929
/* Priorities taken from DPLASMA */
757930
auto nt = A.cols();
758931
tt_potrf->set_priomap([nt](const Key1& key) { return ((nt - key[0]) * (nt - key[0]) * (nt - key[0])); });
@@ -761,10 +934,33 @@ namespace potrf {
761934
});
762935
tt_syrk->set_priomap(
763936
[nt](const Key2& key) { return ((nt - key[0]) * (nt - key[0]) * (nt - key[0]) + 3 * (key[0] - key[1])); });
764-
tt_gemm->set_priomap([nt](const Key3& key) {
765-
return ((nt - key[0]) * (nt - key[0]) * (nt - key[0]) + 3 * ((2 * nt) - key[0] - key[1] - 3) * (key[0] - key[1]) +
766-
6 * (key[0] - key[2]));
767-
});
937+
938+
// GEMM: pick the plain or batching-enabled implementation (driver's choice);
939+
// apply the common setup to whichever concrete TT gets constructed, then
940+
// erase to TTBase to store alongside the other TTs.
941+
auto configure_gemm = [&](auto tt) -> std::unique_ptr<ttg::TTBase> {
942+
tt->set_keymap(keymap3);
943+
tt->set_defer_writer(defer_write);
944+
#ifdef ENABLE_DEVICE_KERNEL
945+
if (enable_device_map) {
946+
tt->set_devicemap(devmap3);
947+
}
948+
#endif // 0
949+
tt->set_priomap([nt](const Key3& key) {
950+
return ((nt - key[0]) * (nt - key[0]) * (nt - key[0]) + 3 * ((2 * nt) - key[0] - key[1] - 3) * (key[0] - key[1]) +
951+
6 * (key[0] - key[2]));
952+
});
953+
return tt;
954+
};
955+
std::unique_ptr<ttg::TTBase> tt_gemm;
956+
#ifdef ENABLE_DEVICE_KERNEL
957+
if (enable_gemm_batching) {
958+
tt_gemm = configure_gemm(make_gemm_batched(A, disp_gemm, trsm_gemm_row, trsm_gemm_col, gemm_gemm, gemm_trsm, gemm_gemm));
959+
} else
960+
#endif // ENABLE_DEVICE_KERNEL
961+
{
962+
tt_gemm = configure_gemm(make_gemm(A, disp_gemm, trsm_gemm_row, trsm_gemm_col, gemm_gemm, gemm_trsm, gemm_gemm));
963+
}
768964

769965
auto ins = std::make_tuple(tt_dispatch->template in<0>());
770966
auto outs = std::make_tuple(tt_potrf->template out<0>());

examples/potrf/testing_dpotrf.cc

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ int main(int argc, char **argv)
7070
/* whether we set a device mapping */
7171
bool enable_device_map = !cmdOptionExists(argv, argv+argc, "--default-device-map");
7272

73+
/* whether to batch GEMM kernel submissions (see make_gemm_batched) */
74+
bool enable_gemm_batching = cmdOptionExists(argv, argv+argc, "--gemm-batching");
75+
7376
// TODO: need to filter out our arguments to make parsec happy
7477
ttg::initialize(1, argv, nthreads);
7578

@@ -136,7 +139,7 @@ int main(int argc, char **argv)
136139
ttg::Edge<Key2, MatrixTile<double>> result("To result");
137140

138141
auto potrf_init_tt = make_load_tt(A, startup, cow_hint);
139-
auto potrf_ttg = potrf::make_potrf_ttg(A, startup, result, cow_hint, enable_device_map);
142+
auto potrf_ttg = potrf::make_potrf_ttg(A, startup, result, cow_hint, enable_device_map, enable_gemm_batching);
140143
auto potrf_result_ttg = make_result_ttg(A, result, cow_hint);
141144

142145
auto connected = make_graph_executable(potrf_init_tt.get());
@@ -184,7 +187,7 @@ int main(int argc, char **argv)
184187
ttg::Edge<Key2, MatrixTile<double>> toresult("To Result");
185188

186189
auto init_tt = make_load_tt(A, topotrf, cow_hint);
187-
auto potrf_ttg = potrf::make_potrf_ttg(A, topotrf, toresult, cow_hint);
190+
auto potrf_ttg = potrf::make_potrf_ttg(A, topotrf, toresult, cow_hint, enable_device_map, enable_gemm_batching);
188191
auto result2_ttg = make_result_ttg(A, toresult, cow_hint);
189192

190193
bool connected = make_graph_executable(init_tt.get());

0 commit comments

Comments
 (0)