Using the JobSystem in Raftel Engine

This tutorial introduces Raftel Engine's JobSystem, a multi-threaded task execution system that helps you improve performance by distributing work across multiple CPU cores.

What is JobSystem?

The JobSystem is a thread pool implementation that allows you to execute tasks concurrently across multiple CPU cores. It's particularly useful for:

Thread Safety Note

When using the JobSystem, be careful with tasks that modify shared resources. Use proper synchronization mechanisms (mutexes, atomic variables) to avoid data races.

Basic Usage

Using the JobSystem involves three simple steps:

Basic JobSystem Usage
#include "raftel/job_system.hpp"

int main() {
  // Step 1: Create the JobSystem
  auto jobSystem = Raftel::JobSystem::make();
  
  // Step 2: Submit work to the JobSystem
  auto futureResult = jobSystem->AddWork([](int a, int b) {
    return a + b;
  }, 5, 3);
  
  // Step 3: Get the result when needed
  int result = futureResult.get();  // This will be 8
  
  return 0;
}

Creating the JobSystem

To start using the JobSystem, you first need to create an instance:

Creating a JobSystem
#include "raftel/job_system.hpp"

// Create the JobSystem
auto jobSystem = Raftel::JobSystem::make();

The JobSystem creates a thread pool with one thread per CPU core by default, maximizing your potential for parallel execution.

Submitting Tasks

You can submit tasks to the JobSystem using the AddWork method. This method accepts a function (or lambda) and its arguments:

Simple Task

// Submit a simple task
auto future = jobSystem->AddWork([]() {
  return "Task completed";
});

Task with Parameters

// Submit a task with parameters
auto future = jobSystem->AddWork([](int a, int b) {
  return a * b;
}, 10, 20);

Task with Member Function

// Submit a task that calls a member function
auto future = jobSystem->AddWork(&MyClass::compute, 
                               myObject, param1, param2);

Void Task (No Return Value)

// Submit a task with no return value
auto future = jobSystem->AddWork([](const std::string& msg) {
  std::cout << msg << std::endl;
}, "Processing...");

Understanding Return Values

The AddWork method returns a std::future object that will eventually contain the result of the task. The future's template type will match the return type of your function.

Getting Results

Once you've submitted a task, you can get its result using the get() method of the returned future:

Getting Task Results
// Submit a calculation
auto futureResult = jobSystem->AddWork([](int a, int b) {
  return a + b;
}, 5, 3);

// Do other work here...

// Get the result when needed
int result = futureResult.get();  // This will block until the result is ready

The get() method will block the calling thread until the result is available. If you want to check if the result is ready without blocking, you can use wait_for() instead:

Non-blocking Result Check
// Check if the result is ready without blocking
if (futureResult.wait_for(std::chrono::seconds(0)) == std::future_status::ready) {
  // Result is ready
  int result = futureResult.get();
  // Use the result...
} else {
  // Result is not ready yet
  // Continue with other work...
}

Practical Example: Asynchronous Mesh Loading

A common use case for the JobSystem is loading assets in the background to avoid freezing the rendering loop. Here's an example of asynchronously loading a 3D mesh:

Asynchronous Mesh Loading
#include "raftel/window.hpp"
#include "raftel/mesh.hpp"
#include "raftel/shader.hpp"
#include "raftel/job_system.hpp"
#include "raftel/input.hpp"

int main(void) {
  auto windowSystemOpt = Raftel::WindowSystem::make();
  auto windowOpt = Raftel::Window::make("Job System Demo", *windowSystemOpt);
  auto jobSystem = Raftel::JobSystem::make();
  
  if (!windowOpt) {
    std::cerr << "Error creating window.\n";
    return -1;
  }
  
  windowOpt->MakeContextCurrent();
  
  // Define mesh paths
  std::vector meshPaths = {
    "../assets/obj/luffytrial.obj",
    "../assets/obj/cube.obj",
    "../assets/obj/sword.obj",
  };
  size_t currentMeshIndex = 0;
  
  // Load initial mesh
  Raftel::Mesh myModel;
  Raftel::ShaderProgram shaderProgram;
  
  if (!myModel.loadMesh(meshPaths[currentMeshIndex])) {
    std::cerr << "Error loading initial mesh.\n";
    return -1;
  }
  
  // Set up texture and material
  auto texture = Raftel::Texture::loadTexture("../assets/textures/luffytextrial.png");
  auto material = std::make_shared();
  material->setAlbedo(texture);
  myModel.setMaterial(material);
  myModel.setupMesh();
  
  // Load shader
  if (!shaderProgram.load("../assets/shaders/test.vs", "../assets/shaders/test.fs")) {
    std::cerr << "Error loading shaders.\n";
    return -1;
  }
  
  // Variables for async loading
  bool isLoading = false;
  std::future> futureMesh;
  
  // Main render loop
  while (!windowOpt->ShouldClose()) {
    windowOpt->input->updateKeys();
    windowOpt->clear();
    
    // Check for mesh switch request
    if (windowOpt->input->isKeyPressed(Raftel::Input::Keys::Key_Space) && !isLoading) {
      isLoading = true;
      
      // Choose next mesh index
      size_t nextMeshIndex = (currentMeshIndex + 1) % meshPaths.size();
      
      // Submit mesh loading job to JobSystem
      futureMesh = jobSystem->AddWork([&meshPaths, nextMeshIndex]() {
        auto tempModel = std::make_shared();
        if (!tempModel->loadMesh(meshPaths[nextMeshIndex])) {
          std::cerr << "Error loading mesh: " << meshPaths[nextMeshIndex] << "\n";
        }
        return tempModel;
      });
      
      currentMeshIndex = nextMeshIndex;
    }
    
    // Check if async loading is complete
    if (futureMesh.valid()) {
      if (futureMesh.wait_for(std::chrono::seconds(0)) == std::future_status::ready) {
        // Mesh loading is complete
        auto result = futureMesh.get();
        result->setupMesh();
        myModel = std::move(*result);
        isLoading = false;
      }
    }
    
    // Render current mesh
    shaderProgram.use();
    myModel.setUniforms(shaderProgram.GetProgramID());
    myModel.draw(shaderProgram);
    
    windowOpt->swapBuffers();
  }
  
  return 0;
}

In this example:

  1. We load an initial mesh synchronously at startup
  2. When the user presses Space, we submit a job to load the next mesh in the background
  3. We continue rendering the current mesh while the new one loads
  4. We check each frame if the loading job has completed
  5. When the job completes, we apply the new mesh

This pattern keeps the application responsive while performing intensive operations like mesh loading.

Best Practices

Task Granularity

Create tasks that are substantial enough to offset the overhead of thread switching. Very small tasks may not benefit from parallelization.

Avoid Thread Contention

Design tasks to work on independent data to minimize synchronization needs and maximize parallelism.

Thread Safety

Use proper synchronization (mutexes, atomic variables) when tasks need to access shared resources.

Futures Management

Always check if a future is valid before calling get() or wait_for() to avoid undefined behavior.

When to Use JobSystem

The JobSystem is best used for:

GPU Operations

Remember that the JobSystem runs tasks on CPU threads. For GPU-bound operations, parallelizing with JobSystem may not improve performance since the bottleneck is the GPU, not the CPU.

Complete API Reference

The JobSystem API is simple but powerful:

JobSystem API
namespace Raftel {
  class JobSystem {
  public:
    // Create a JobSystem instance
    static std::unique_ptr make();
    
    // Add work to the JobSystem
    template
    auto AddWork(Function&& function, Args&&... args) 
        -> std::future;
  };
}

For complete details, see the JobSystem API Reference.