// TaskScheduler.cpp : Defines the entry point for the console application. // #include "rbx/TaskScheduler.h" #include "rbx/TaskScheduler.Job.h" #include "rbx/Debug.h" #include "FastLog.h" #include "rbx/boost.hpp" #include "boost/scoped_ptr.hpp" #include "rbx/ProcessPerfCounter.h" #include "rbx/Profiler.h" using namespace RBX; using boost::shared_ptr; //#define TASKSCHEDULAR_PROFILING LOGVARIABLE(TaskSchedulerTiming, 0) FASTFLAGVARIABLE(TaskSchedulerCyclicExecutive, false) FASTFLAGVARIABLE(DebugTaskSchedulerProfiling, false) DYNAMIC_FASTINTVARIABLE(TaskSchedularBatchErrorCalcFPS, 300) DYNAMIC_FASTFLAGVARIABLE(CyclicExecutiveForServerTweaks, false) RBX::TaskScheduler::PriorityMethod TaskScheduler::priorityMethod = AccumulatedError; //RBX::TaskScheduler::PriorityMethod TaskScheduler::priorityMethod = FIFO; #ifdef RBX_TEST_BUILD int TaskScheduler::findJobFPS = 100; bool TaskScheduler::updateJobPriorityOnWake = false; #endif static const float minFrameDelta60Hz1every3 = 0.0159; static const float minFrameDelta60Hz2every3 = 0.0169; double TaskScheduler::getSchedulerDutyCyclePerThread() const { return threads.size() ? schedulerDutyCycle.dutyCycle() / threads.size() : 0; } rbx::thread_specific_reference TaskScheduler::currentJob; struct PrintTaskSchedulerItem { int count; double averageError; double averagePriority; double dutyCycle; double averageStepsPerSecond; double coefficient_of_variation; double averageStepTime; PrintTaskSchedulerItem() :count(0) ,averageError(0) ,averagePriority(0) ,dutyCycle(0) ,averageStepsPerSecond(0) ,coefficient_of_variation(0) ,averageStepTime(0) { } }; static std::string computeKey(const RBX::TaskScheduler::Job* job) { std::string key = job->name; size_t index = key.find(':'); if (index==std::string::npos) return key; index = key.find_last_of(' '); key = job->name.substr(0, index); return key; } void PrintArbiters(std::vector >& jobs) { std::set > arbiters; for (std::vector >::iterator iter = jobs.begin(); iter!=jobs.end(); ++iter) { if ((*iter)->getArbiter()) arbiters.insert((*iter)->getArbiter()); } double total = 0; for (std::set >::iterator iter = arbiters.begin(); iter!=arbiters.end(); ++iter) { double activity = (*iter)->getAverageActivity(); printf("Arbiter %s\t%.1f%%\t%s\n", (*iter)->arbiterName().c_str(), 100.0 * activity, (*iter)->isThrottled() ? "throttled" : ""); total += (*iter)->getAverageActivity(); } printf("Total activity\t%.1f%%\n", 100.0 * total); } void PrintTasks(std::vector >& jobs) { printf("%25.25s\tSleep\tPrior\t%%\tSteps\tCV\tStep\n", "Arbiter:TaskName"); for (std::vector >::iterator iter = jobs.begin(); iter!=jobs.end(); ++iter) { if ((*iter)->getSleepingTime()>Time::Interval(2)) printf("%25.25s\tAsleep for %.1fs\n", (*iter)->getDebugName().c_str(), (*iter)->getSleepingTime().seconds()); else printf("%25.25s\t%d%%\t%.1g\t%.1f%%\t%.1f/s\t%.1f%%\t%.3fs\n", (*iter)->getDebugName().c_str(), int(100.0 * (*iter)->averageSleepRate()), (*iter)->getPriority(), 100.0 * (*iter)->averageDutyCycle(), (*iter)->averageStepsPerSecond(), 100.0 * (*iter)->getStepStats().stepInterval().coefficient_of_variation(), (*iter)->averageStepTime()); } } void PrintAggregatedTasks(std::vector >& jobs) { std::map items; for (std::vector >::iterator iter = jobs.begin(); iter!=jobs.end(); ++iter) { std::string key = computeKey(iter->get()); if ((*iter)->getSleepingTime()averageError(); item.averagePriority += (*iter)->getPriority(); item.dutyCycle += (*iter)->averageDutyCycle(); item.averageStepsPerSecond += (*iter)->averageStepsPerSecond(); item.coefficient_of_variation += (*iter)->getStepStats().stepInterval().coefficient_of_variation(); item.averageStepTime += (*iter)->averageStepTime(); } } printf("%15.15s\tCount\tError\tPrior\t%%\tSteps\tCV\tStep\n", "Task"); for (std::map::iterator iter = items.begin(); iter!=items.end(); ++iter) { PrintTaskSchedulerItem& item(iter->second); printf("%15.15s\t%d\t%.2f\t%.1f\t%.1f%%\t%.1f/s\t%.1f%%\t%.3fs\n", iter->first.c_str(), item.count, item.averageError / (double)item.count, item.averagePriority / (double)item.count, 100.0 * item.dutyCycle, item.averageStepsPerSecond / (double)item.count, 100.0 * item.coefficient_of_variation / (double)item.count, item.averageStepTime / (double)item.count ); } } void TaskScheduler::printDiagnostics(bool aggregateJobs) { std::vector > jobs; getJobsInfo(jobs); if (aggregateJobs) PrintAggregatedTasks(jobs); else PrintTasks(jobs); PrintArbiters(jobs); printf("sleep %.1f, wait %.1f, run %.2f, affinity %.2f, scheduling %.1f/s (%.2g%%)\n", numSleepingJobs(), numWaitingJobs(), numRunningJobs(), threadAffinity(), schedulerRate(), getSchedulerDutyCyclePerThread()*100 ); printf("\n"); } RBX::ExclusiveArbiter RBX::ExclusiveArbiter::singleton; bool RBX::ExclusiveArbiter::areExclusive(TaskScheduler::Job* task1, TaskScheduler::Job* task2) { RBXASSERT(task1->hasArbiter(this)); RBXASSERT(task2->hasArbiter(this)); return true; } static TaskScheduler* sing; void TaskScheduler::static_init() { static TaskScheduler s; s.setThreadCount(TaskScheduler::Auto); // Use auto by default sing = &s; } TaskScheduler& TaskScheduler::singleton() { static boost::once_flag flag = BOOST_ONCE_INIT; boost::call_once(static_init, flag); return *sing; } const static double lerpTaskScheduler = 0.05; TaskScheduler::TaskScheduler() :sampleRunningJobCountEvent(true) ,threadCount(0) ,threadAffinityPreference(1.5) // TODO: Come up with a good number here ,runningJobCount(0) ,waitingJobCount(lerpTaskScheduler) ,sleepingJobCount(lerpTaskScheduler) ,averageRunningJobCount(lerpTaskScheduler) ,schedulerDutyCycle(lerpTaskScheduler) ,averageThreadAffinity(lerpTaskScheduler, 1) ,nextWakeTime(Time::max()) ,lastSortTime(Time()) ,desiredThreadCount(0) ,cyclicExecutiveLoopId(0) ,DataModel30fpsThrottle(true) ,cyclicExecutiveWaitForNextFrame(false) ,nonCyclicJobsToDo(0) ,lastCyclcTimestamp(Time::now()) { runningJobCounterThread.reset(new boost::thread(RBX::thread_wrapper(boost::bind(&TaskScheduler::sampleRunningJobCount, this), "Roblox sampleRunningJobCount"))); // Publish the fast flag out to the schedulers API so that it can be overridden by specific clients. // E.g. this is not yet something to be done on the server. cyclicExecutiveEnabled = FFlag::TaskSchedulerCyclicExecutive; } TaskScheduler::~TaskScheduler() { FASTLOG(FLog::TaskSchedulerInit, "Destroying TaskScheduler"); sampleRunningJobCountEvent.Set(); runningJobCounterThread->join(); endAllThreads(); } void TaskScheduler::remove(boost::shared_ptr task, bool joinTask, boost::function callbackPing) { if (!task) return; // You can't join to yourself, but logically // there should be no concurrency risk to not join joinTask &= task.get() != currentJob.get(); shared_ptr joinEvent(joinTask ? new CEvent(true) : NULL); remove(task, joinEvent); if (joinTask) { if (callbackPing) { for (int i = 0; i < 10 * 60 * 5; ++i) { callbackPing(); if (joinEvent->Wait(100)) return; } #ifdef _DEBUG RBXASSERT(false); // Why did it take so long to join? #endif RBXCRASH(); // We want to learn about this. Blocking a long time means a thread in the pool is locked up! } else { if (!joinEvent->Wait(1000 * 60 * 5)) { #ifdef _DEBUG RBXASSERT(false); // Why did it take so long to join? #endif RBXCRASH(); // We want to learn about this. Blocking a long time means a thread in the pool is locked up! } } } } void TaskScheduler::reschedule(boost::shared_ptr job) { { RBX::mutex::scoped_lock lock(mutex); if (job->SleepingHook::is_linked()) { RBXASSERT( !cyclicExecutiveEnabled || job->cyclicExecutive == false ); sleepingJobs.erase(sleepingJobs.iterator_to(*job)); scheduleJob(*job); } else if(cyclicExecutiveEnabled && job->cyclicExecutive) { CyclicExecutiveJobs::iterator i = std::find( cyclicExecutiveJobs.begin(), cyclicExecutiveJobs.end(), job ); RBXASSERT( i != cyclicExecutiveJobs.end() ); if( i != cyclicExecutiveJobs.end() ) { // Reschedule immediately without waiting for other jobs. Prevents theoretical deadlocks. i->cyclicExecutiveExecuted = false; } } } } bool TaskScheduler::jobCompare(const CyclicExecutiveJob& jobA, const CyclicExecutiveJob& jobB) { return (jobA.job->cyclicPriority < jobB.job->cyclicPriority); } void TaskScheduler::add(boost::shared_ptr job) { RBX::mutex::scoped_lock lock(mutex); RBXASSERT(allJobs.find(job)==allJobs.end()); allJobs.insert(job); if(cyclicExecutiveEnabled) { if( job->cyclicExecutive ) { RBXASSERT( std::find( cyclicExecutiveJobs.begin(), cyclicExecutiveJobs.end(), job ) == cyclicExecutiveJobs.end() ); cyclicExecutiveJobs.push_back( job ); std::sort(cyclicExecutiveJobs.begin(), cyclicExecutiveJobs.end(), jobCompare); } else { scheduleJob(*job); } } else { job->cyclicExecutive = false; scheduleJob(*job); } } template void insert_from_back(List& list, Item& f, IsLess isLess) { for (typename List::reverse_iterator iter = list.rbegin(); iter!=list.rend(); ++iter) if (!isLess(f, *iter)) { list.insert(iter.base(), f); return; } list.push_front(f); } void TaskScheduler::scheduleJob(Job& job) { if(cyclicExecutiveEnabled && job.cyclicExecutive) { CyclicExecutiveJobs::iterator i = std::find( cyclicExecutiveJobs.begin(), cyclicExecutiveJobs.end(), job ); RBXASSERT( i != cyclicExecutiveJobs.end() ); if( i != cyclicExecutiveJobs.end() ) { i->isRunning = false; } return; } job.updateWakeTime(); const Time now = Time::now(); if (job.wakeTime <= now) { #ifdef RBX_TEST_BUILD errorCalculationPerSec.sample(); #endif job.updateError(now); if (job.currentError.error > 0) { job.sleepRate.sample(0); job.startWaiting(); #ifdef RBX_TEST_BUILD if (updateJobPriorityOnWake) #endif { job.updatePriority(); } enqueueWaitingJob(job); return; } } job.sleepRate.sample(1); job.startSleeping(); insert_from_back(sleepingJobs, job, Job::isLowerWakeTime); } void TaskScheduler::remove(const boost::shared_ptr& job, boost::shared_ptr joinEvent) { RBX::mutex::scoped_lock lock(mutex); RBXASSERT(allJobs.find(job)!=allJobs.end()); FASTLOG1(FLog::TaskSchedulerRun, "Removing job %p from allJobs (::remove)", job.get()); allJobs.erase(job); if (job->SleepingHook::is_linked()) { RBXASSERT( !cyclicExecutiveEnabled || job->cyclicExecutive == false ); FASTLOG1(FLog::TaskSchedulerRun, "Removing job %p from sleepingJobs (::remove)", job.get()); sleepingJobs.erase(sleepingJobs.iterator_to(*job)); if (joinEvent) joinEvent->Set(); } else if (job->WaitingHook::is_linked()) { RBXASSERT( !cyclicExecutiveEnabled || job->cyclicExecutive == false ); FASTLOG1(FLog::TaskSchedulerRun, "Removing job %p from waitingJobs (::remove)", job.get()); waitingJobs.erase(waitingJobs.iterator_to(*job)); if (joinEvent) joinEvent->Set(); } else { if (cyclicExecutiveEnabled && job->cyclicExecutive) { CyclicExecutiveJobs::iterator i = std::find( cyclicExecutiveJobs.begin(), cyclicExecutiveJobs.end(), job ); RBXASSERT( i != cyclicExecutiveJobs.end() ); if( i != cyclicExecutiveJobs.end() ) { if( !i->isRunning ) { cyclicExecutiveJobs.erase(i); if (joinEvent) joinEvent->Set(); return; } } } if (joinEvent) { RBXASSERT(!job->joinEvent); // Did somebody else already try to remove this task???? // TODO: Support multiple joins by using a collection? // We must wait for the task to be completed before signaling the joinEvent job->joinEvent = joinEvent; } // We can't remove it yet. Wait for it to finish stepping job->isRemoveRequested = true; } } void TaskScheduler::incrementThreadCount() { ++threadCount; } void TaskScheduler::decrementThreadCount() { --threadCount; } bool TaskScheduler::areExclusive(TaskScheduler::Job* task1, TaskScheduler::Job* task2, const shared_ptr& arbiterHint) { if (Job::haveDifferentArbiters(task1, task2)) return false; // different Arbiter domains can run concurrently if (!arbiterHint) return false; // No Arbiter means the task can run concurrently return arbiterHint->areExclusive(task1, task2); } void TaskScheduler::sampleRunningJobCount() { while (!sampleRunningJobCountEvent.Wait(71)) { waitingJobCount.sample(int(waitingJobs.size()+cyclicExecutiveJobs.size()) ); sleepingJobCount.sample(int(sleepingJobs.size())); averageRunningJobCount.sample(int(runningJobCount)); } } void TaskScheduler::enqueueWaitingJob(Job& job) { FASTLOG1(FLog::TaskSchedulerFindJob, "Adding job %p to waitingJobs (::enqueueWaitingJob)", &job); RBXASSERT( !cyclicExecutiveEnabled || job.cyclicExecutive == false ); waitingJobs.push_back(job); } Time::Interval TaskScheduler::getShortestSleepTime() const { return nextWakeTime - Time::now(); } void TaskScheduler::wakeSleepingJobs() { Time now = Time::now(); SleepingJobs::iterator iter = sleepingJobs.begin(); while (iter!=sleepingJobs.end()) { Job& job(*iter); RBXASSERT( !cyclicExecutiveEnabled || job.cyclicExecutive == false ); if (job.wakeTime > now) { nextWakeTime = job.wakeTime; return; } else { iter = sleepingJobs.erase(iter); #ifdef RBX_TEST_BUILD errorCalculationPerSec.sample(); #endif job.updateError(now); #ifdef RBX_TEST_BUILD if (updateJobPriorityOnWake) #endif { job.updatePriority(); } enqueueWaitingJob(job); } } nextWakeTime = Time::max(); } void TaskScheduler::checkStillWaitingNextFrame(Time now) { /// Since the Task Scheduler reasons at 0.001 intervals, in order to run smoothly at 60Hz we must /// alternate between having a delay of 0.016, and then two delays of 0.017 /// Instead of using 0.016, we have to account for the inacuracy in timestamp. It's safe to /// use 0.0155 as 0.016 and 0.0165 as 0.017 in this scenario since time is only sampled at 0.001. double minTimespan = minFrameDelta60Hz2every3; if (cyclicExecutiveLoopId % 3 == 1) { minTimespan = minFrameDelta60Hz1every3; // do nothing (unless uncommented) } if ((now - lastCyclcTimestamp).seconds() < minTimespan && true) { // Do nothing } else { FASTLOG1F(FLog::TaskSchedulerTiming, "Starting new Cycle after: %4.7f", (float)(Time::now() - lastCyclcTimestamp).seconds()); lastCyclcTimestamp = now; cyclicExecutiveWaitForNextFrame = false; cyclicExecutiveLoopId++; if (cyclicExecutiveLoopId == 3) cyclicExecutiveLoopId = 0; } } boost::shared_ptr TaskScheduler::findJobToRun(shared_ptr requestingThread) { #ifdef TASKSCHEDULAR_PROFILING int theCount = 0; RBX::Timer timer; #endif Time now = Time::now(); if(cyclicExecutiveEnabled) { // Once all "cyclic jobs" have been done, this code falls through to the low priority queue one time. bool hasRemaingJobs=false; if ((nonCyclicJobsToDo == 0) && cyclicExecutiveWaitForNextFrame && DataModel30fpsThrottle) { checkStillWaitingNextFrame(now); } if (!cyclicExecutiveWaitForNextFrame || !DataModel30fpsThrottle) { for (CyclicExecutiveJobs::iterator iter = cyclicExecutiveJobs.begin(); iter != cyclicExecutiveJobs.end(); ++iter) { Job& job(*iter->job); if( iter->cyclicExecutiveExecuted ) { continue; } if (job.isDisabled()) continue; hasRemaingJobs = true; if( iter->isRunning ) { continue; } if (conflictsWithScheduledJob(&job)) continue; if (job.tryJobAgain()) { break; } iter->cyclicExecutiveExecuted = true; job.updateError(now); if( job.currentError.isDefault() ) { // Various jobs, including all the rendering tasks, signal they do not want // to run by setting their error to 0. continue; } iter->isRunning = true; shared_ptr result = job.shared_from_this(); FASTLOG2(FLog::TaskSchedulerFindJob, "CyclicExecutive, job: %p, arbiter %p", result.get(), result->getArbiter().get() ); return result; } if( !hasRemaingJobs ) { for (CyclicExecutiveJobs::iterator iter = cyclicExecutiveJobs.begin(); iter != cyclicExecutiveJobs.end(); ++iter) { iter->cyclicExecutiveExecuted = false; } cyclicExecutiveWaitForNextFrame = true; nonCyclicJobsToDo = numNonCyclicJobsWithWork(); FASTLOG1F(FLog::TaskSchedulerTiming, "Finished all jobs in Cycle in: %4.7f", (float)(Time::now() - lastCyclcTimestamp).seconds()); } } if (nonCyclicJobsToDo > 0) { // Run nonCyclicExecutive jobs that have been in the Queue before moving on to // the next CyclicExecutive frame shared_ptr result = findJobToRunNonCyclicJobs(requestingThread, now); if (result) { // At end of Cyclic RenderJob will have DataModel Lock // We must make sure that we only decrement jobs to do when // we do them, because we are almost guaranteed that result // will be null the first few checks. nonCyclicJobsToDo--; } else if ( numNonCyclicJobsWithWork() == 0 ) { // In case something modifies the jobs after end of cycle // we want to be able to verify that job's error didn't // return to 0 nonCyclicJobsToDo = 0; } return result; } else { // If tryJobAgain breaks out of the loop, we want the opportunity to do // work on Non-Cyclic jobs while we wait for job to be ready. shared_ptr result = findJobToRunNonCyclicJobs(requestingThread, now); return result; } } else // !cyclicExecutiveEnabled { // nextScheduledJob is an optimization. Under heavy load, we cut the time for the scheduler // in half because it buffers a job from the last go-around. // TODO: Buffer n jobs instead? // TODO: Buffer one job per thread to help with thread affinity? if (nextScheduledJob) { shared_ptr result(nextScheduledJob); nextScheduledJob.reset(); if (result->WaitingHook::is_linked()) if (!result->isDisabled()) if (!conflictsWithScheduledJob(result.get())) { FASTLOG1(FLog::TaskSchedulerFindJob, "Removing nextScheduledJob %p from waitingJobs (::findJobToRun)", result.get()); waitingJobs.erase(waitingJobs.iterator_to(*result)); averageThreadAffinity.sample(result->lastThreadUsed.lock() == requestingThread); return result; } } return findJobToRunNonCyclicJobs(requestingThread, now); } } int TaskScheduler::numNonCyclicJobsWithWork() { int jobCount = 0; for (WaitingJobs::iterator iter = waitingJobs.begin(); iter != waitingJobs.end(); ++iter) { Job& job(*iter); if (job.currentError.error > 0 && !job.isDisabled()) { // We only add jobs that have work to do! jobCount++; } } return jobCount; } shared_ptr TaskScheduler::findJobToRunNonCyclicJobs(boost::shared_ptr requestingThread, RBX::Time now) { shared_ptr result; wakeSleepingJobs(); WaitingJobs::iterator bestJob = waitingJobs.end(); // Note: These definitions don't strictly need to be initialized, but it eliminates pesky compiler warnings bool bestHasAffinity = false; bool bestIsThrottled = false; bool shouldCalcError; Time::Interval timeSinceLastSorting = now - lastSortTime; int fps = DFInt::TaskSchedularBatchErrorCalcFPS; if (fps < 10) { fps = 10; } double errorCalcInterval = 1.f / (double)fps; #ifdef RBX_TEST_BUILD errorCalcInterval = 1.f / findJobFPS; #endif shouldCalcError = (timeSinceLastSorting.seconds() > errorCalcInterval); if (shouldCalcError) { #ifdef RBX_TEST_BUILD sortFrequency.sample(); #endif lastSortTime = now; } FASTLOG1(FLog::TaskSchedulerFindJob, "Starting to iterate through waitingJobs. Size = %d", waitingJobs.size()); for (WaitingJobs::iterator iter = waitingJobs.begin(); iter != waitingJobs.end(); ++iter) { #ifdef TASKSCHEDULAR_PROFILING theCount++; #endif Job& job(*iter); if (job.isDisabled()) continue; if (priorityMethod != FIFO) { if (shouldCalcError) { #ifdef RBX_TEST_BUILD errorCalculationPerSec.sample(); #endif job.updateError(now); } if (job.currentError.error<=0) { continue; } } if (conflictsWithScheduledJob(&job)) { continue; } if (shouldCalcError) { job.updatePriority(); } const bool hasAffinity = job.lastThreadUsed.lock() == requestingThread; bool match = false; shared_ptr const arbiter(job.getArbiter()); if (bestJob == waitingJobs.end()) { match = true; } else if (job.currentError.urgent != bestJob->currentError.urgent) { if (job.currentError.urgent) match = true; } else if (bestIsThrottled && (!arbiter || !arbiter->isThrottled())) { match = true; } else if (hasAffinity == bestHasAffinity) { if (job.priority > bestJob->priority) match = true; } else if (hasAffinity) { if (job.priority * threadAffinityPreference > bestJob->priority) match = true; } else { if (job.priority > bestJob->priority * threadAffinityPreference) match = true; } if (match) { if(!cyclicExecutiveEnabled) { // Cache off old best job if (bestJob != waitingJobs.end()) { if (!areExclusive(&job, &*bestJob, arbiter)) { nextScheduledJob = bestJob->shared_from_this(); } else if (nextScheduledJob && areExclusive(&job, &*nextScheduledJob, arbiter)) { nextScheduledJob.reset(); } } } bestJob = iter; bestHasAffinity = hasAffinity; if (priorityMethod == FIFO) break; bestIsThrottled = arbiter && arbiter->isThrottled(); } } FASTLOG(FLog::TaskSchedulerFindJob, "Finished iterating through waitingJobs"); if (bestJob != waitingJobs.end()) { result = bestJob->shared_from_this(); RBXASSERT(bestJob->WaitingHook::is_linked()); waitingJobs.erase(waitingJobs.iterator_to(*bestJob)); averageThreadAffinity.sample(bestHasAffinity); FASTLOG3(FLog::TaskSchedulerFindJob, "RunJob, job: %p, arbiter %p error: %u", result.get(), result->getArbiter().get(), (unsigned)result->currentError.error); } #ifdef TASKSCHEDULAR_PROFILING if (FFlag::DebugTaskSchedulerProfiling) { static RBX::Timer totalTimer; static int totalCount = 0; static double taskSchedulerTime = 0.f; static int lastTotalCount = 0; double timeElapsed = timer.delta().seconds(); taskSchedulerTime += timeElapsed; totalCount += theCount; if (totalCount - lastTotalCount >= 1000000) { lastTotalCount = totalCount; double totalTimeElapsed = totalTimer.delta().seconds(); printf("Jobs: %d, time used: %f, total time: %f, job/s: %d, percent: %.2f%%\n", totalCount, taskSchedulerTime, totalTimeElapsed, (int)((double)totalCount / totalTimeElapsed), taskSchedulerTime / totalTimeElapsed * 100.f); } } #endif return result; } rbx::atomic RBX::SimpleThrottlingArbiter::arbiterCount; bool RBX::SimpleThrottlingArbiter::isThrottlingEnabled = false;