Skip to content
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

Multithreading Implementation #45

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open

Conversation

Sir-NoChill
Copy link
Collaborator

Implemented Multi-Threaded test running 🧵

I dis-aggregated the test running and the result printing, spawning threads to execute each sub-package worth of tests independently. The number of threads is limited by a command line option.

Current Rough Edges

Termination Behavior

There is a long delay after the termination of the testing and the threads being cleaned up, though I think that could be fixed with the implementation I propose in the relevant commit.

Timing Accuracy

I'm not sure how much accuracy of timed tests will be affected by the threaded implementation. This is something to test, and in the worst case (that it does affect the timing a lot), we should repeat the timings with warm up and timed runs until we get a reasonable amount of data to average. This would be simple to implement, but I don't want to do so right now.

Load Balancing

The load balancing might suck a little bit here to begin with, but I propose a better architecture in my commit that implements the threading behavior. I did not implement it because it's going to be weird enough that I will need help from someone who actually knows how to write C++.

@JustinMeimar
Copy link
Collaborator

This looks great, I think we should leave it in this branch for now and test it out when marking generator.

Did you try testing the Gazprea solution with multi-threading? I'm curious to see how fast it was.

Nice work!

TestSet now contains data on the success of the test in a pair
Put the data into the optional. This was surprisingly non-trivial

I really hate C++
Simply spawns a thread for every toolchain.

Note that this would probably brick a computer without a lot of
cores or running tests with a whole bunch of packages.

Implementation will need to limit the number of spawnable threads
in the future.
Currently there is a delay when we try to kill the threads, I'm
not entirely sure why, but it makes it seem like the process takes
a long time to die. Something to investigate in the future, but
threading is fully implemented.
**Batch Size**

Not necessary since the threads will just grab everything in their
subpackage. In the future it would be nice to, instead of
seperating based on packages:
1. Generate the set of all tests
2. Threads access batchSize tests at a time
3. Threads then keep referring back to the test pool for which
    tests need executing

This would eliminate the need for constantly restarting threads,
but would take a bit of a refactor to implement correctly.

**Strict**

I'm tired. I hate writing C++. I'm going to sleep. Here's my PR.
Forgot to move it from the printing function to the execution
one.
Previously, the threads were individually executing an entire
toolchain on their own, switched that to actually do the subpackage
like I claimed it did.
Ensured that the grader does not try to execute everything in a
single thread, waiting for the tests to finish asynchronously.
In preparation for the use of the set generation tactic I mentioned
for better load balancing in the children
@Sir-NoChill Sir-NoChill force-pushed the ayrton-multithreading branch from 0e0a4aa to 2e44d29 Compare September 9, 2024 01:22
@@ -54,14 +56,21 @@ class TestHarness {

private:
// The results of the tests.
// NOTE we keep both a result manager and
// the result in the TestSet to ensure in-ordre
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ordre -> order

Copy link
Contributor

@kuzi117 kuzi117 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work, this is a big endeavor, I just want to make sure everything is thread safe here and a couple minor comments on C++ references.

Will comment on the multithreading architecture in the main thread.

// test running
bool runTestsForToolChain(std::string tcId, std::string exeName);
void threadRunTestsForToolChain(std::string tcId, std::string exeName, std::reference_wrapper<SubPackage> subpackage);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this std::reference_wrapper and not just SubPackage&? Typical use for std::reference_wrapper is for storage in containers or template shenanigans.

}

// spawn a new thread executing its tests
std::thread t(&TestHarness::threadRunTestsForToolChain, this, tcPair.first, exePair.first, std::ref(subpackage.second));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can just use subpackage.second if you convert the parameter to SubPackage&.

@@ -17,7 +18,8 @@ namespace fs = std::filesystem;
namespace tester {

// Test hierarchy types
typedef std::vector<std::unique_ptr<TestFile>> SubPackage;
typedef std::pair<std::unique_ptr<TestFile>, std::optional<TestResult>> TestPair;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realize these feel like much the same thing, but this shouldn't be std::option, it should be using the promise/future mechanism(std::promise, std::future). It has the same sort of mechanism as std::option for being in a limbo state with no value, but the outward appearance is immediately apparent to those familiar with the terms. "Option" should be for a value that may or may not have a value, "promise" is for a promised value that will arrive, just in the future, "future" is for getting that promised value. std::future also comes with the busy wait mechanism built in at std::future::wait.

In particular, std::optional should be replaced here with std::promise.

// Poll while we wait for the result
// TODO this could probably be replaced with some sort of interrupt,
// (and probably should be), but better this than no threads
while (!testpair.second.has_value())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

std::future::wait here, or std::future::wait_for if you want a time out and give up, though the tester should be guaranteed to return since it has its own timeout.

Technically, I think option is probably not thread safe, so std::optional::has_value might return true before the value is set. I believe std::future is purpose-built to handle this.


void swap(TestResult &other) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any reason that this can't just be std::swap on two instances of TestResult to begin with?

bool error;
std::string diff;

TestResult clone() { return TestResult(this->name, this->pass, this->error, this->diff); }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a copy constructor in TestResult, namely TestResult(const TestResult &other).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking further, I'm not sure this is ever necessary. But if it were, it should be the copy constructor or possibly operator=(const TestResult &other).


// keep the result with the test for pretty printing
std::optional<TestResult> res_clone = std::make_optional(result.clone());
subPackage[i].second.swap(res_clone);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These three lines don't do anything.

pair is subPackage[i]. result is a duplicate of subPackage[i].second.value. res_clone is a an optional of subPackage[i].second.value. Then you swap subPackage[i].second with that value, which results in no change.

Can you explain what the intent is here?


TestResult result = runTest(test.get(), toolChain, cfg);
// keep the result with the test for pretty printing
std::optional<TestResult> res_clone = std::make_optional(result.clone());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no need to clone here, since make_optional is just doing the same thing under the hood. std::optional doesn't store a reference (unless you're asking it to), so std::make_optional is going to call TestResult(const TestResult &) as part of initializing the stored value anyways.

In any case, if this is changed to promise/future, this line isn't needed anyways, since the next line should just change to using std::promise::set_value. No need to clone again.

@Sir-NoChill
Copy link
Collaborator Author

I should note that I am currently working on the load balancing fix I mentioned in the summary, though that might be best as a seperate PR anyways

@kuzi117
Copy link
Contributor

kuzi117 commented Sep 10, 2024

The way I see it, you have two options, one which I would lean on since you already have the mechanism set up.

  1. I wouldn't pick this way, but it's here for completeness and you might like it. You can use std::async which returns a future immediately (the promise is managed internally) that you can wait on at a later time. Passing std::launch::async will have it put on another thread. Passing std::launch::deferred will just lazily evaluate (almost certainly don't want this). You can put them together since it's a bit mask, but it would be bad to defer it to later. This option is slightly annoying since you'll need to rework your evaluation mechanism to be function based, probably a free function, but it does free you from having to manually spawn and join threads. Unfortunately, spawning a bunch of threads this way is slow (you might not care) and it's not guaranteed there's a thread pool underlying it since it's compiler defined implementation behavior.
  2. ... but since you already have a thread pool, you might as well do that. My approach would be to have a work queue and a mutex lock. Fill the queue with work (packages to process) then spawn your threads. Threads start by trying to acquire a mutex (std::mutex::lock will block until it's available), on success they pop an item from the queue then release the mutex for another thread to allow it to grab work. They chug through the work, fill the promises, then go back to the mutex to get more work. If they get to the queue and it's empty, they quit.

I think you're already 75% of the way there with the implementation that's currently here. Just a bit of rearranging and some extra variables and you're good to go. Let me know if you have any questions.

@kuzi117
Copy link
Contributor

kuzi117 commented Sep 10, 2024

if you want to chat with me directly/more quickly about anything I put up above, you can email me at my ualberta email and I'll pass on my discord info or we can work something else out. Don't want to paste it here for fear of spam scrapers. It's braedy[at]ualberta.

Had an indexing error due to a malformed ternary, all works now
Now just to make the actual printing happen in the proper sequence
}
}

void TestHarness::threadRunTestsForToolChain(std::reference_wrapper<std::vector<std::string>> tcNames,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do believe that instead of std::reference_wrapper here, they should be just std::vector<XXX>&. Could be const as well.


void TestHarness::threadRunTestsForToolChain(std::reference_wrapper<std::vector<std::string>> tcNames,
std::reference_wrapper<std::vector<std::string>> exeNames,
std::reference_wrapper<std::vector<std::reference_wrapper<TestPair>>> tests,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The internal one here is correct. It should be std::vector<std::reference_wrapper<TestPair>>&.

@kuzi117
Copy link
Contributor

kuzi117 commented Sep 17, 2024

Nice work, that update looks like it should be really close to the final version. Just update those reference_wrapper uses and I'll take a look after. You didn't switch to using promises, but I think as long as you spawn threads, do work, and join before any other part of the main thread gets to examine the results, there should be no race conditions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants