#polymorphism with templated base

230 messages Β· Page 1 of 1 (latest)

solid obsidian
#

I'm basically trying to create some sort of generator, now the problem i ran into is that the interface is well templated and in other places i generally only want to accept the generator interface itself. Any ideas?

#include <iostream>
#include <tuple>
#include <optional>
#include <functional>
#include <memory>

template <typename... ARGS>
class Generator {
   public:
    virtual ~Generator() = default;
    virtual std::optional<std::tuple<ARGS...>> next() = 0;
    virtual void reset() = 0;
};

class IntGenerator : public Generator<int> {
   public:
    IntGenerator(int start, int end) : start(start), end(end) {}

    std::optional<std::tuple<int>> next() override {
        if (start < end) {
            return std::make_tuple(start++);
        } else {
            return std::nullopt;
        }
    }

    void reset() override { start = 0; }

   private:
    int start;
    int end;
};

void do_something(std::tuple<int> generator) {}

template <typename FUNC>
void foo(Generator &generator, FUNC func) {
    while (true) {
        auto result = generator.next();
        if (result.has_value()) {
            func(result.value());
        } else {
            break;
        }
    }
}

int main(int argc, char const *argv[]) {
    auto generator = std::make_unique<IntGenerator>(0, 10);
    foo(*generator, do_something);

    return 0;
}
lilac quiverBOT
#

When your question is answered use !solved to mark the question as resolved.

Remember to ask specific questions, provide necessary details, and reduce your question to its simplest form. For tips on how to ask a good question use !howto ask.

languid gyro
#

then you can just do

template <typename Generator, typename FUNC> requires is_generator<Generator>
void foo(Generator &generator, FUNC func) {
    while (generator.hasNext()) {
        auto result = generator.next();
        if (result.has_value()) {
            func(result.value());
        }
    }
}```
#

Without needing everything else

solid obsidian
#

cause im working with c++17 :p

languid gyro
#

auto &?

solid obsidian
#

i guess so

grim umbra
#

well depends what your end goal is

#

in places where you want to accept "any" kind of generator, with your setup you must use a template no matter what

#

if you do not want to use a template but still generate arbitrary type values, you're going to have issues

#

if you're fine using templates, then there are questions about what kind of constraints/lack thereof you wish to have

#

e.g. if you need nicer/early diagnostics, or if you need to control overload resolution/specialization selection
or just for clarity

solid obsidian
#

well i guess a plain old simple template is enough and then once it's c++20 i could use concepts

In the end i wanted to create a threadpool which just fetches the data from the iterator, i.e.

class ThreadPool {
   public:
    ThreadPool(size_t num) {}
    ~ThreadPool() {}
    template <typename GENERATOR, typename Func>
    void execute(GENERATOR& generator, Func func) {}

    void wait() {}
};
int main() {
    IntGenerator generator(0, 10);

    ThreadPool pool(3);

    pool.execute(generator, [](std::tuple<int> data) {
        std::stringstream ss;
        ss << "Thread ID: " << std::this_thread::get_id() << " | Data: " << std::get<0>(data)
           << std::endl;

        std::cout << ss.str();
    });

    pool.wait();

    return 0;
}
grim umbra
#

I honestly wouldn't bake that into the thread pool itself most of the time tbh

solid obsidian
#

well i want to avoid filling the threadpool with all tasks at the beginning because a) lazy evaluation b) some early termination doesnt require to compute all tasks

grim umbra
#

that part is fine, but I'd either

  • wrap the whole thing into the one singular task, or
  • make a task that queues more task
    not bake and intertwine the generator together with the pool
languid gyro
#

write the correct code first, if it's too slow then optimize

#

write code with performance in mind, but don't make it a priority

solid obsidian
#

sure, but i wanted to implement something similar to pythons yield

languid gyro
#

trying to write python in C++ will not be a good time

solid obsidian
#

just because πŸ™ƒ

solid obsidian
languid gyro
#

What are you trying to do here?

#

What's the goal of this generator interface

grim umbra
#

no, if you have the one task it isn't gonna be split up across threads, it's gonna be entirely on the same thread

solid obsidian
#

sorry

grim umbra
#

which, if you want to lazily fetch data, is arguably fine

solid obsidian
#

i meant wouldnt

languid gyro
#

Actually

#

why not associate a generator with the thread pool instead of what you're doing right now?

#

That makes much more sense imo

solid obsidian
#

mh can you explain a bit more what you mean?

languid gyro
#

Right now, thread pools know about generators

#

and generators don't know about thread pools

#

reverse that

#

a thread pool shouldn't know about what's using it

#

all it knows is "i have these tasks to do"

grim umbra
#

ok so you want to spread your load across threads in the pool, but somehow only fetch data when a thread is ready, but what happens to the initial thread

languid gyro
#

it shouldn't care what those tasks are

#

(also cancelling a task is stupidly cheap)

#

(so launching them all at once, and then exiting in them early if they should be cancelled is fine, but i digress)

solid obsidian
#

well this is what sly said no

#

more or less

languid gyro
#

Do you get what I mean with reversing the association?

solid obsidian
#

yeah, but i only want to call next() when theres actually a thread ready to execute the function as well

grim umbra
#

cause no matter how you slice it, the pool has to somehow know a thread is available, so I don't get how you intend to "skip" the checking that a thread is ready
unless your plan is to store the generator somewhre in the pool and let worker thread invoke the generator to get a new task lazily

languid gyro
#

yeah, but i only want to call next() when theres actually a thread ready to execute the function as well
why?

#

you shouldn't care

#

someone using the thread pool shouldn't care if there's a thread ready

#

That's a big reason to use a thread pool

#

You are just saying "do this at some point on another thread"

#

that's it

grim umbra
#

and if that's your plan, then either you're spawning threads when you call the templated method (which partially defeats the point of the pool), or you actually need a generic interface to grab tasks that do not depend on the generator output type

#

because if you have an actual dependency on the generator output type, and do not erase it, you can't quite store that generator into your pool

#

and even then I'd still extract that logic outside of the pool

languid gyro
#

this is why you shouldn't even have thread pools care about the generator

grim umbra
#

and let the calling thread deal with it

languid gyro
#

and it should be the other way around

#

there is no reason to do it another way (in this situation)

grim umbra
#

said the other way around, the only generator that would make sense in a thread pool, would be "task generators"

#

which can be implemented to produce tasks lazily, or produce them eagerly and store them in some queue

#

but consumming a data generator + a function gets weird imo

solid obsidian
#

Well the entire idea behind this specialized threadpool is that it picks the data itself when actually needed. Instead of prefilling the entire threadpool. Imagine some gamee, the generator creates possible combinations of the game's state and a thread tests this combination, now the game is complex and there are 10^120 solutions or something, creating everything in the beginning wouldnt work, so each thread should use create a combination when it finished the last one. Sure there are other ways to split this up better but I just want it that way :p

grim umbra
#

having a utility wrapper that converts that into a task generator is fine, but I'd have that outside most of the time

languid gyro
#

Don't design for "what ifs"

#

Design for your current problem

solid obsidian
#

i dont have a problem XD

languid gyro
#

If you get to that "what if" you deal with it then and there

languid gyro
#

So for any reasonable scale, just do it the simple way and you're fine

solid obsidian
#

argh

languid gyro
#

(Trust me, you don't want to deal with that scale)

solid obsidian
#

I just want a generator which creates the next element in the sequence and delegates that data to a function which is executed in another thread.

languid gyro
#

then do that

#

you're not doing that right now

#

right now you're creating a thread pool that can take a generator and execute it for you

#

they seem similar, but they aren't

solid obsidian
#

argh if you take me that precise

languid gyro
#
template <typename... ARGS>
class Generator {
   protected:
    thread_pool *pool = nullptr;
   public:
    Generator(thread_pool *pool = nullptr) : pool(pool) {}
    virtual ~Generator() = default;
    virtual std::optional<std::tuple<ARGS...>> next() = 0;
    virtual void reset() = 0;

    template <typename Func>
    void generate() {
      while (true) {
          auto result = next();
          if (result.has_value()) {
              pool->enqueue(func, result.value());
          } else {
              break;
          }
      }
    }
};```
solid obsidian
languid gyro
#

there

#

done

#

thats it (barring probably some syntax stuff)

languid gyro
#

that's going to hurt performance more than help (likely)

solid obsidian
#

why would it

languid gyro
#

because if you have every task preloaded, it's all there

#

the code isn't doing other stuff to prepare a task

#

if you don't do your "feature", your execution looks like this
create tasks
execute tasks

solid obsidian
#

okay the world isnt one sided on performance, memory wise it will likely be better?

languid gyro
#

probably

#

are you creating gigabytes of tasks?

solid obsidian
#

lets say i am

languid gyro
#

I really don't know how to put what I want to explain

solid obsidian
#

buddy

#

i know about premature optimization

languid gyro
#

That's not what I want to say

#

If you're in a position where you have that problem, you likely would already know how to solve it the best way for the requirements you have.

grim umbra
languid gyro
solid obsidian
#

i have

#

never said that.

grim umbra
#

but taking in an arbitrary generator still doesn't mesh with a thread pool design

languid gyro
#

So I'm putting it in the way they're concerned about

#

It seemed like it

#

Why else would you be doing it like this?

#

If you're trying to save memory, you said it yourself. Memory is also performance πŸ˜›

grim umbra
#

well depends on how long running the thread pool is, tbh

languid gyro
#

There's a lot of factors

#

If this was a situation that had strict requirements, it would be a lot easier

#

That's a big "problem" that I see a lot of beginners face

#

When they're doing things, since they don't have strict requirements, overdesigning tends to happen quite often,. (At least this is what I've seen in my years of tutoring)

#

Which led me to just telling beginners to implement it the simple way, and if there's a problem you can explain in one sentence. "The thread pool uses too much memory", then now you have a strict requirement you can solve

#

But solving problems that don't exist just leads to a bad time overall

solid obsidian
#

I just like the idea that a thread picks up the work itself without having to pregenerate all possible tasks. That came to my head and that I wanted to implemented, so that's what I did.

languid gyro
#

Fair enough

#

And to be completely fair, I can see I'm being a bit of a dick head

#

So, sorry about that. I'll help you out with what your original question

#

I really went off on a tangent, and I do really apologize for that.

solid obsidian
#

πŸ˜„

#

well i have something which works anyway

languid gyro
#

Anyways, do you agree with what I was saying about the association? (Once we get on the same page here, we can build up the feature you want)

solid obsidian
#

you mean moving it to the generator?

languid gyro
#

Yes

#

Instead of the thread pool knowing about the generator

solid obsidian
#

Yes good idea, but then the thread pool needs some logic which signals that some thread is ready for a new task?

languid gyro
#

Yep

#

But then think about how simple it is

#

let's expand this

template <typename... ARGS>
class Generator {
   protected:
    thread_pool *pool = nullptr;
   public:
    Generator(thread_pool *pool = nullptr) : pool(pool) {}
    virtual ~Generator() = default;
    virtual std::optional<std::tuple<ARGS...>> next() = 0;
    virtual void reset() = 0;

    template <typename Func>
    void generate() {
      while (true) {
          auto result = next();
          if (result.has_value()) {
              pool->enqueue(func, result.value());
          } else {
              break;
          }
      }
    }
};```
#

all we need to do is make it so we wait until a thread is ready in the thread pool

#

As much as I'm not a fan, you can busy wait

#
template <typename... ARGS>
class Generator {
   protected:
    thread_pool *pool = nullptr;
   public:
    Generator(thread_pool *pool = nullptr) : pool(pool) {}
    virtual ~Generator() = default;
    virtual std::optional<std::tuple<ARGS...>> next() = 0;
    virtual void reset() = 0;

    template <typename Func>
    void generate() {
      while (true) {
          auto result = next();
          if (result.has_value()) {
              while (!pool.is_thread_ready()) {
                pool->enqueue(func, result.value());
              }
          } else {
              break;
          }
      }
    }
};```
#

thats it

#

and then in the thread pool, you can just keep a single std::uint8_t of running_threads

solid obsidian
#

i guess i can just set some atomic bool in the threadpool

languid gyro
#

and just cpp [[nodiscard]] bool is_thread_ready() noexcept { return running_threads == threads.size(); }

#

(I would suggest a std::atomic<std::uint8_t>, but same difference)

solid obsidian
#

well you are destroying the idea behind a threadpool now a bit

languid gyro
#

How so?

solid obsidian
#

the threads are created once

languid gyro
#

Yeah?

solid obsidian
#

oh nvm i see what you mean

languid gyro
#

Oh, it doesn't even need to be atomic actually

#

(assuming you don't enqueue tasks from different threads)

languid gyro
#

and now the thread pool is still just a thread pool

grim umbra
#

I still think this whole thing should just use a task generator

#

and I'm firmly against this busy wait implementation >:)

solid obsidian
#

you mean coroutines?

grim umbra
#

no

solid obsidian
#

then what is a task generator

grim umbra
#

a generator<task>

#

like your generator<int>

#

but for tasks

languid gyro
#

i used the generator to generate generators

grim umbra
#

like there's no way to have a thread pool without a concept of tasks

#

the only way you could have that is if you spawn a thread for every single job/task which defeats the point

#

of the pool

languid gyro
grim umbra
#

maybe you're using std::function<void()> instead of a task, but essentially the same thing

solid obsidian
#

not sure what you are trying to say πŸ˜„ my threadpool before caio mentioned their idea was creating a task out of generator data and the function supplied while keeping the threads alive

grim umbra
#

a thread in a thread pool is meant to service multiple tasks

#

so a basic setup for threads in a threadpool is pulling those tasks out of some queue of tasks or generator of tasks or what have you

#

so you save yourself the cost of spawning threads every time

#

and you also have a mechanism to avoid having too many threads compared to what your cpu can actually do

#

so if there are too many tasks that come in, they have to "wait" in some way, instead of spawning a ton of threads

solid obsidian
#
    template <typename GENERATOR, typename Func>
    void execute(GENERATOR& generator, Func func) {
        for (size_t i = 0; i < numThreads; ++i) {
            threads.emplace_back([this, &generator, &func] {
                while (!shutdown) {
                    std::unique_lock<std::mutex> lock(generator_m);

                    auto result = generator.next();

                    if (result.has_value()) {
                        lock.unlock();
                        func(result.value());
                    } else {
                        return;
                    }
                }
            });
        }
    }

i.e. this is from the old implemention

grim umbra
#

I don't know what your exact setup would have initially been, but your generator is templated to produce different data, and that data type cannot appear in the task type, unless you spawn dedicated threads in your pool that can only deal with that specific data type

#

which is what I say partially defeats the point of the pool

grim umbra
#

threads is some container of threads I assume

#

wait actually

#

this one is one of the suggestion I gave you but that you shot down

solid obsidian
#

when πŸ˜„

#

this function is still in the threadpool

grim umbra
#

this is exactly what I meant when I said to wrap the thing in a singular task

#

you have one thread that does everything

#

and you said it's no good

solid obsidian
#

but I dont?

grim umbra
#

you do

#

what's threads

#

is it not a container of std::thread?

solid obsidian
#

std::vector<std::thread> threads;

grim umbra
#

yeah so you exactly do

#

you literally do

#

you have one thread doing everything

#

for that one generator

solid obsidian
#

but no?

#

multiple threads are working on the data from the generator

grim umbra
#

no

#
[this, &generator, &func] {
                while (!shutdown) {
                    std::unique_lock<std::mutex> lock(generator_m);

                    auto result = generator.next();

                    if (result.has_value()) {
                        lock.unlock();
                        func(result.value());
                    } else {
                        return;
                    }
                }
            }

is a lambda

#

you created one thread and gave it that one lambda

#

that one thread will pull data from the generator, and call func itself

#

unless func queues tasks into the thread pool, that means there's one thread doing everything

solid obsidian
#

yes but I can spawn N threads to do this lambda

grim umbra
#

no

#

there's one thread for the lambda

#

you can spawn N thread for N different generators

solid obsidian
#
int main() {
    IntGenerator generator(0, 10);

    ThreadPool pool(3);

    pool.execute(generator, [](std::tuple<int> data) {
        std::stringstream ss;
        ss << "Thread ID: " << std::this_thread::get_id() << " | Data: " << std::get<0>(data)
           << std::endl;

        std::cout << ss.str();
    });

    pool.wait();

    return 0;
}
Thread ID: 3 | Data: 0
Thread ID: 4 | Data: 1
Thread ID: 2 | Data: 2
Thread ID: 3 | Data: 3
Thread ID: 3 | Data: 6
Thread ID: 2 | Data: 5
Thread ID: 4 | Data: 4
Thread ID: 4 | Data: 9
Thread ID: 2 | Data: 8
Thread ID: 3 | Data: 7

there are multiple threads working one generator.

grim umbra
#

you cannot spawn N threads for the one generator referenced by that one lambda

solid obsidian
#

then why the hell is it working?

grim umbra
#

produce a reproducible example

grim umbra
solid obsidian
grim umbra
#

apologies

solid obsidian
#

mah lord

#

πŸ˜„

grim umbra
#

so you create N threads to reference the one generator and yes it works

#

I'm sill saying this should be a task generator so you don't need to create those threads everytime

solid obsidian
#

well define everytime, they are valid for as long as the generator valid, sure when i want to execute a new generator then yea i will need new ones

grim umbra
#

if in your main you call two generators that have different generating types you spawn twice the number of threads

solid obsidian
#

but then with the appraoch from caio i have a simple thread pool and the generator creates the tasks instead of just the data which will then work with multiple generators

grim umbra
#

also one where the busy wait has horrific performance

#

I guess I should just take an hour to eat then write an example

#

would be simpler to discuss the difference

solid obsidian
#

sure πŸ˜„ im taking some 8 hours of sleep in the meantime then :p

grim umbra
#

@solid obsidian whipped a fairly ugly example https://godbolt.org/z/3Ksv95WaK

#

when I went through the original file there were some bits I really wanted to kick out, like the wait thing

#

since there are some annoying lifetime considerations you might want or not want to deal with

#

it would probably make more sense to let for_each wait directly internally, or maybe even contribute to executing the tasks

#

instead of relying on the caller to keep the generator/iterator/range alive for long enough and hope he calls wait before anything dangles

#

also I mostly kept your generator as-is but honestly in c++ I'd do this completely differently because ranges are a thing

#

even before c++20 the overall concept/idea is present, and for simple cases I'd rather have range-for compatibility than not, but well