< Previous post | Next post >

[2026-10-21][Daily]

First steps implementing dynamic adaptive task granularity

This post follows the experiments regarding the auto-adaptive task granularity in the previous post. In previous experiments, we hard-coded different task granularity to perform preliminary benchmarks, but here, we investigate how to design such a mechanism. The following subjects are mentionned, feel free to skip to the results at the end:

StarPU model

As a quick reminder, let's see how we define tasks in StarPU:

void cpu_func(void *buffers[], void *cl_arg)
{
    printf("Hello world\n");
}
 
struct starpu_codelet cl =
{
    .cpu_funcs = { cpu_func },
    .nbuffers = 0
};

Then we call it:

struct starpu_task *task = starpu_task_create();

task->cl = &cl; /* Pointer to the codelet defined above */

/* starpu_task_submit will be a blocking call. If unset,
starpu_task_wait() needs to be called after submitting the task. */
task->synchronous = 1;

/* submit the task to StarPU */
starpu_task_submit(task);

Ideally, we want that every aspect of our program could be launched as a task, or that the children function be called as tasks. An obvious limitation is the signature of StarPU functions (void)(void *buffers[], void *cl_arg).

Another limitation concerns data object such as vectors, matrices etc. Let's define a function more useful than our "hello world":

void scal_cpu_func(void *buffers[], void *cl_arg)
{
    unsigned i;
    float *factor = cl_arg;
 
    /* length of the vector */
    unsigned n = STARPU_VECTOR_GET_NX(buffers[0]);
    /* CPU copy of the vector pointer */
    float *val = (float *)STARPU_VECTOR_GET_PTR(buffers[0]);
 
    for (i = 0; i < n; i++)
        val[i] *= *factor;
}

Here, a vector is retrieved using STARPU_VECTOR_GET_PTR and its dimensions with STARPU_VECTOR_GET_NX. Internally, the macros get these informations from a starpu structure defined as follows:

struct starpu_vector_interface
{
	enum starpu_data_interface_id id; /**< Identifier of the interface */

	uintptr_t ptr;	      /**< local pointer of the vector */
	uintptr_t dev_handle; /**< device handle of the vector. */
	size_t offset;	      /**< offset in the vector */
	size_t nx;	          /**< number of elements on the x-axis of the vector */
	size_t elemsize;      /**< size of the elements of the vector */
	size_t slice_base;    /**< vector slice base, used by the StarPU OpenMP runtime support */
	size_t allocsize;     /**< size actually currently allocated */
};

This structure interface is a representation of the data for a particular task on a specific device. For example, on a GPU, or specific CPU core, the pointer and/or offset/elemsize could be different.

The interface should clearly be differentiated from the StarPU handles. The handles are defined in the main thread, and are used to determine coherency and their usage in the tasks. An handle would be defined as:

float vector[NX];
 
starpu_data_handle_t vector_handle;
starpu_vector_data_register(&vector_handle, STARPU_MAIN_RAM, (uintptr_t)vector, NX, sizeof(vector[0]));

It is of course a limitation: in my idea, a function could be either executed as a StarPU task, or executed normally as a regular C function inside a worker. If we define a new data inside a worker, we don't need (and cannot) to instanciate a StarPU handle. We could just directly use the raw data.

The same applies for data partitioning: when accessed from tasks, we simply use the children handles, but when invoked inside a worker (i.e. we are already in a task), we can directly access the data "by hand". Let me show you an example, here is how I partition a matrix in DAHL using StarPU data filters:

dahl_partition_vector dahl_matrix_partition_along_y(dahl_matrix const matrix)
{
    size_t const nparts = dahl_matrix_get_shape(matrix).ny;

    struct starpu_data_filter f =
	{
		.filter_func = starpu_matrix_filter_pick_vector_y,
		.nchildren = nparts,
		.get_child_ops = starpu_matrix_filter_pick_vector_child_ops
	};

    dahl_arena* origin = starpu_data_get_user_data(matrix);
    return dahl_partition_init(matrix, nparts, &f, origin);
}

With the dahl partition looking like this:

typedef struct
{
    // Handle of the parent's data structure which have been partitioned
    starpu_data_handle_t parent;
    // Number of children
    size_t nparts;
    // List of the children handles
    starpu_data_handle_t* children;
} dahl_partition;

Then, we can iterate over the children to submit a task. However, if we are in on a worker thread, we don't have access to the handles, so we should be able to access sub-data "by hand". Here is a little code sample that would manage this:

void* block_get_matrix_z(void* blk, size_t z)
{
    if (!ctx_on_worker)
    {
        dahl_partition* part = (dahl_partition*)starpu_data_get_user_data(blk);
        return part->children[z];
    }
    else
    {
        struct starpu_block_interface* interface = blk;
        struct starpu_matrix_interface* mat = malloc(sizeof(struct starpu_matrix_interface));
        mat->nx = interface->nx;
        mat->ny = interface->ny;
        mat->ld = interface->ldy;
        mat->ptr = (uintptr_t)&((dahl_fp*)interface->ptr)[interface->ldz * z];
        mat->elemsize = sizeof(dahl_fp);
        mat->id = STARPU_MATRIX_INTERFACE_ID;

        return mat;
    }
}

We'll get back to the condition (!ctx_on_worker) later, but keep in mind that it is used to determine wether the call is from the main thread, or a worker. When called from the main thread, we get the partition object and get the correct starpu handle with children[z]. When running from a worker, it creates a struct starpu_matrix_interface* with the correct values.

Here a huge limitation is that the data should be contiguous. Indeed, we are able to get the pointer to the children using &((dahl_fp*)interface->ptr)[interface->ldz * z]; because matrices in the block are initialized contiguously. If we wanted a different data partitioning, e.g. partitioning over the X axis, we would have to handle that ourselves, because starpu won't be able to do that in the context of a worker execution.

To summarize, here are the different limitations I have identified yet:

Auto-adaptive task granularity

To implementation auto-adaptive task granularity, I think we need to distinguish 3 execution contexts:

Here is an example of an execution tree:

Example of functions being expanded into regular functions, tasks, or inlined functions.

Let's detail particular execution path of this tree:

With such a mechanism, we could dynamically scale the granularity of our tasks easily. A high granularity would be expanding at the leaves, like for D, E. A medium granularity would be F, as the task will do and G and H job. A low granularity would be I, as the task will perform the job from 6 different functions.

The most extreme example would be no granularity at all: A is launched as a single task and does all the job on one thread. This could make sense if we had such a small workload that there is not point in parallelizing it (energy or runtime wise).

Implementation time!

As we discussed, there are many limitations and challenges that we need to overcome to implement this. Here are my first steps into implementing such a mechanism.

First steps

The first issue is that I have to pass either a handle, or an interface depending on the execution context. My first idea was to implement a tagged union:

typedef struct
{
    bool is_handle;
    union {
        starpu_data_handle_t handle; /* StarPU handle  */
        void *interface;             /* Data Interface */
    } data;
} dahl_handle;

Thus I can write things like this:

void dahl_convolution_filters(dahl_blk sample, dahl_tsr filters, dahl_blk output)
{
    for (size_t f = 0; f < NUM_FILTERS; f++)
    {
        launch_convolution(
            sample,
            tensor_get_block_t(filters, f),
            block_get_matrix_z(output, f)
        );
    }
}

Here the function has no clue if these are handles or interfaces, but it does not matter. Only functions tensor_get_block_t and block_get_matrix_z will check the actual type, to return either a sub-handle, either a sub-interface. Then we can implement the expand logic on top of it:

void real_launch_convolution_filters(dahl_blk sample, dahl_tsr filters, dahl_blk output)
{
    if (sample.is_handle && GRANULARITY == 1)
    {
        assert(sample.is_handle && filters.is_handle && output.is_handle);

        // Launch the function as a task
        int ret = starpu_task_insert(&my_cl_convolution_filters,
            STARPU_R, sample.data.handle,
            STARPU_R, filters.data.handle,
            STARPU_W, output.data.handle, 0);

        STARPU_CHECK_RETURN_VALUE(ret, "couldn't insert task");
    }
    else
    {
        // Here, sample could either be inlined (thus passing interfaces), or maybe we have handles
        // but the GRANULARITY consraints haven't been respected (thus it passes handles).
        dahl_convolution_filters(sample, filters, output);
    }
}

Here the expansion logic is a single if/else: If the objects are handles AND the granularity condition is met, we can expand the function as a task. Else, we simply call the function in a regular way. In the else branch, we could either have handle objects, if the granularity condition was not met, or interfaces if the current function is already in a task.

The last remaining bit is to provide a wrapper to call dahl_convolution_filters as a StarPU task. As we've seen, tasks should have (void)(void**,void*), which is not the case here. So we can implement a small wrapper:

// Small adapter to wrap interfaces into a dahl_handle object
#define MAKE_HANDLE(INTERFACE) (dahl_handle) { .is_handle = false, .data.interface = (INTERFACE) }

void launch_convolution_filters(void* buffer[], void* cl_arg)
{
    // Actually call the function inside the task
    dahl_convolution_filters(
        MAKE_HANDLE(STARPU_BLOCK_GET(buffer[0])),
        MAKE_HANDLE(STARPU_TENSOR_GET(buffer[1])),
        MAKE_HANDLE(STARPU_BLOCK_GET(buffer[2]))
    );
}

In summary, this looks like this:

     |
     | Handle or Interface
     v
   +-------------------------------+
   |real_launch_convolution_filters|----------+ Handle
   +-------------------------------+          v
     |                                +--------------------------+
     | Handle or Interface            |launch_convolution_filters|
     v                                +--------------------------+
   +------------------------+                 |
   |dahl_convolution_filters| <---------------+ Interface
   +------------------------+
     |
     | Calling another function...
     v

When real launch is called with interfaces as arguments, it can only call the real implementation "inlined" (red tasks). If called with handles, and the granularity criteria is met, it is launched with the starpu wrapper function, which calls the implementation (blue tasks). If not met, it passes the handle to the function (green tasks).

So this implementation works, but it has an obvious drawback: WE NEED 3 FUNCTIONS PER TASK. In my example I had something like this:

     |
     | Handle or Interface
     v
   +-------------------------------+
   |real_launch_convolution_filters|----------+ Handle
   +-------------------------------+          v
     |                                +--------------------------+
     | Handle or Interface            |launch_convolution_filters|
     v                                +--------------------------+
   +------------------------+                 |
   |dahl_convolution_filters| <---------------+ Interface
   +------------------------+
     |
     | For f in range(filters)
     |
     | Handle or Interface
     v
   +--------------------------+
   |real_launch_convolution_2d|-----+ Handle
   +--------------------------+     v
     |                           +---------------------+
     | Handle or Interface       |launch_convolution_2d|
     v                           +---------------------+
   +-------------------+            |
   |dahl_convolution_2d| <----------+ Interface
   +-------------------+
     This is a leaf, stopping here

While this issue could be solved using macros (I did), it makes the code barely readable and very hard to maintain. Let's investigate how to simplify this pattern.

Best implementation so far

In fact, using the tagged union dahl_handle introduces a lot of useless boilerplate. This is why I need to have many wrappers to transform interface into dahl_handle. This also complicates things for the launchers. In fact, the tagged union indicates an information that can be guessed by the scheduling context: if we are on the main thread (green tasks), we always manipulate handles, on worker threads, we deal with interfaces (red and blue tasks). So instead of storing this information in data objects, we can simply use a thread_local variable :

// Defined globally, indicates if current thread is a worker.
thread_local bool ctx_on_worker = true;

// At the beginning of main, we simply turn it to false. Only the main thread will be false.
int main(int argc, char** argv)
{
    dahl_init();
    ctx_on_worker = false;

    // ...
}

Now, we can also refactor function signatures. Because StarPU wants (void)(void**,void*), we won't fight against it, but rather utilize the advantage of using void** for the buffers. The convolution filters becomes:

// Buffers: image sample, kernel, output buffer
// cl_arg: none
void dahl_convolution_filters(void* buffers[], void* cl_arg)
{
    for (size_t f = 0; f < NUM_FILTERS; f++)
    {
        void* sub_buffers[3] = { 
            buffers[0], 
            tensor_get_block_t(buffers[1], f),
            block_get_matrix_z(buffers[2], f)
        };

        dahl_invoke(sub_buffers, cl_arg, 3, 0, &my_cl_cconvolution_2d);
    }
}

Like in a regular task, we know in advance how many buffers we expect (it should be well documented of course). However, here it doesn't matter if the buffers are pointers to interfaces or handles. block_get_matrix_z however does, and will be able to know thanks to ctx_on_worker. Then, we can call dahl_invoke which contains the expansion logic:


// Invoke (either as a task or inlined) a function cl.
// Pass to it buffers and cl_arg.
// You must provide the number of buffers and the granularity level for the expansion condition.
void dahl_invoke(
    void* buffers[], void* cl_arg, 
    size_t nbuffers, size_t granularity_level,
    struct starpu_codelet* cl)
{
    assert(nbuffers > 0);

    // If not on worker thread, and expansion condition is met
    if (!ctx_on_worker && granularity_level == GRANULARITY)
    {
        struct starpu_task *task = starpu_task_create();
        task->cl = cl;

        // Assign handles
        for (size_t i = 0; i < nbuffers; i++)
            task->handles[i] = buffers[i]; 

        // --> Sumbit the function as a task <--
        int ret = starpu_task_submit(task);
        STARPU_CHECK_RETURN_VALUE(ret, "dahl_task_insert");
    }
    // Else, we are on worker thread, or expansion condition is not met
    else
    {
        // --> Simply call the function regularly, passing handles or interfaces <--
        cl->cpu_funcs[0](buffers, cl_arg);
    }
}

This way the convolution itself is still intact, no further modification is required. We simply add assert(ctx_on_worker) as this task is a leaf: no further task can be expanded.

void dahl_cconvolution_2d(void *buffers[], void* cl_arg)
{
    assert(ctx_on_worker);

    auto in = STARPU_BLOCK_GET(buffers[0]);
    auto in_p = (dahl_fp const*)in.ptr;

    // Kernel block
    auto ker = STARPU_BLOCK_GET(buffers[1]);
    auto ker_p = (dahl_fp const*)ker.ptr;

    // Output matrix
    auto out = STARPU_MATRIX_GET(buffers[2]);
    auto out_p = (dahl_fp*)out.ptr;

    // ... implementation ...
}

In conclusion, this implementation is strictly better: we don't need to change current tasks signatures and argument loading (STARPU_BLOCK_GET and all...), we don't need wrappers, and we only need a single invoke function, as it handles arbitrary number of buffers/arguments.

Some results

Let's see this implementation in action. For now I have only implemented the two tasks we've discussed: convolution_filters(block, tensor, tensor), and convolution_2d(block, block, matrix). The first one launches convolution_2d over the filter dimensions. We only have two possible granularities.

Using granularity 0, we obtain tasks lasting around 18~50ms:

Granularity 0, images 406x540x3

Using granularity 1, we obtain tasks lasting around 76~127ms:

Granularity 1, images 406x540x3

Here, we have 4 filters, so in granularity 1, each task does the job of four tasks in granularity 0. Theoretically, if we multiply the run-time of granularity 0 by 4, we should obtain similar values: 18x4=72ms, compared to 76ms, makes sense. 50x4=200ms compared to 127ms. So here it seems that grouping using granularity 1 gives better runtime in the worst case. This could clearly be related to cache effects: in granularity 1, all convolutions for a single image are performed on the same worker. Using granularity 0, we (potentially) launch convolutions for the same image on multiple threads.

(As a side note, the first tasks on the figures are simply image loading tasks. They were not implemented at all with auto-adaptive granularity and don't really matter for these experiments.)

< Previous post | Next post >