Tasks, the progress engine, and completion
S3·E3One core, and they want it back · NVIDIA briefing room, design review, week four of six
Builds on: DOCA Core objects and lifecycle
Before you read: what do you already know?
3 quick questions. Wrong answers are fine and expected; trying first makes the lesson stick.
After this lesson you can
- Trace the ownership of a doca_task from alloc_init through doca_task_submit to the completion callback and to doca_task_free or resubmission.
- Compare polling with doca_pe_progress against event-driven waiting on the notification handle and choose one for a stated latency and CPU target.
- Explain why a started object performs zero allocations on the data path and what that implies for the pool sizes passed to task_set_conf.
- Diagnose a failed task from the error callback path: doca_task_get_status, flushed tasks during Stopping, and a context that enters Stopping after a fatal task error.
Episode 3 — One core, and they want it back
The whiteboard already has the budget on it: one Arm core for the DPU-side archive agent, and not one more. The customer’s storage architect drew that box himself, in front of the NVIDIA PM, who answers the first roadmap question with “not announced” and the second one with “also not announced”. The Dell SE writes the one-core promise down anyway, row 31. Two weeks from now the PoC report carries a CPU column, and the prototype still spins a polling loop that holds that core at a hundred percent whether or not work is arriving. The perf engineer beside the architect is the one who has to live in the box.
The progress engine exists to make that a choice instead of a constraint. Completion handlers “are strictly executed within the context of the doca_pe_progress() function”, so an application defines its event loop in one of two modes: polling, or blocking and notification-driven.[1] In the second, the PE hands out a notification handle that is a Linux file descriptor, armed with doca_pe_request_notification(), after which no progress calls are allowed until the notification is cleared — the pattern the 3.5.0 event sample implements against epoll_wait.[3] The trade is stated plainly by NVIDIA rather than hidden: event-driven mode reduces CPU utilization but may increase latency or reduce performance.[2]
A pinned core is a design decision, so make it one on purpose.
Before either loop makes sense, both engineers need the same picture of what a task is and who owns it at each moment.
1A task changes hands twice
The Core guide defines a task’s life “by a clear ownership model” bounded by the context: “An application can only allocate a task when the owning CTX enters the RUNNING state” and can no longer allocate once it leaves it.[1] Phase 1: allocation with doca_<T>_<J>_task_alloc(ctx, &task) gives the application ownership, configuration with doca_<T>_<J>_task_set_<param>(task, param) keeps it, and doca_task_submit(task) “passes ownership of the task object to the CTX”.[1] Phase 2: the PE executes; the application “must periodically call doca_pe_progress(pe)”, and when the PE detects completion it invokes the handler, at which point “ownership of the task is passed back to the application” and result fields are safe to read.[1] Phase 3: doca_task_free(task) or re-submit.[1]
The DMA sample compresses phase 1 into one call: doca_dma_task_memcpy_alloc_init(resources.dma_ctx, src_doca_buf[i], dst_doca_buf[i], task_user_data[i], &dma_tasks[i]), then doca_dma_task_memcpy_as_task(dma_tasks[i]) for the generic handle and doca_task_submit(tasks); with NVTX profiling it uses doca_task_submit_ex(tasks, DOCA_TASK_SUBMIT_FLAG_FLUSH | DOCA_TASK_SUBMIT_FLAG_RANGE_PROFILING_REQUESTED) and notes the default flag is DOCA_TASK_SUBMIT_FLAG_FLUSH.[5] Two kinds of user data travel with the work: the task’s union doca_data points at a per-task doca_error_t slot, and the context’s user data points at the dma_resources struct, so callbacks can update both without globals.[5][4]
One caution from the PE README: “doca_task_submit does not validate task inputs (to increase performance)”; doca_task_try_submit validates during development and “should not be used in production”.[2]
Every task comes from a pool sized by *_task_*_set_conf(…, num_tasks) and every doca_buf from doca_buf_inventory_create(max_bufs), both fixed before doca_ctx_start. alloc_init / doca_task_free only move items in and out of those pools. DOCA_ERROR_NO_MEMORY from a pool is back-pressure: release finished work, do not retry.
Ownership handoff. doca_task_submit hands the task (and its buffers) to the context; the completion callback hands it back. In between: no read, modify, free or dec_refcount.
doca_dma_task_memcpy_set_conf(dma, memcpy_completed_cb, memcpy_error_cb, num_tasks); doca_pe_connect_ctx(pe, doca_dma_as_ctx(dma)); doca_ctx_start(doca_dma_as_ctx(dma));
Why: set_conf fixes the task pool size and both callbacks while the context is Idle. That pool is why the data path never mallocs.
2Polling or waiting: two ways to drive the PE
After a PE is created “an application can define an event loop using one of these modes: Polling mode, Blocking (notification-driven) mode”, and in both “All completion handlers for both tasks and events are strictly executed within the context of the doca_pe_progress() function”.[1] One call iterates every scheduled unit and stops on a task completion or positive event probe (returning “made some progress”), on considerable partial progress, or falls through with “no progress”.[1] Polling code is therefore a loop; the DMA sample sleeps ten microseconds whenever progress returns zero: while (resources.run_pe_progress) if (doca_pe_progress(state->pe) == 0) nanosleep(&ts, &ts).[5]
Blocking mode replaces the sleep with a file descriptor. The guide: the application gets a notification handle “representing a Linux file descriptor”, arms the PE with doca_pe_request_notification() “every time” it wants a notification, and “After doca_pe_request_notification(), no calls to doca_pe_progress() are allowed” until doca_pe_clear_notification.[1] The 3.5.0 event sample is the reference implementation:
/* doca_event_handle_t is a file descriptor that can be added to an epoll */
EXIT_ON_FAILURE(doca_pe_get_notification_handle(state->base.pe, &event_handle));
epoll_ctl(state->epoll_fd, EPOLL_CTL_ADD, event_handle, &events_in);
...
do {
while (doca_pe_progress(state->base.pe) != 0) {
if (state->base.num_completed_tasks == NUM_TASKS)
return DOCA_SUCCESS;
}
EXIT_ON_FAILURE(doca_pe_request_notification(state->base.pe));
epoll_status = epoll_wait(state->epoll_fd, &ep_event, 1, no_timeout);
/* handle parameter is not used in Linux */
EXIT_ON_FAILURE(doca_pe_clear_notification(state->base.pe, 0));
} while (1);The sample’s own comment states the trade: arming “implies enabling an interrupt, but it also reduces CPU utilization because the program can sleep until the event is fired”.[3] The README is more direct: “Event-driven mode reduces CPU utilization … but may increase latency or reduce performance.”[2] A single PE may host several contexts of one or many types; pe_multi_context connects four DMA contexts to one PE.[1][2]
3Zero allocations on the data path
The Core guide’s reason for the create, configure, start flow is that “All core objects adhere to same flow that later helps in doing no allocations in the fast path”; after start an object “adheres to zero allocations and can be used safely in the data path”.[1] Everything a data path needs is sized while Idle. allocate_dma_resources reads doca_dma_cap_get_max_num_tasks(resources->dma_ctx, &max_tasks_num), rejects a larger request with DOCA_ERROR_INVALID_VALUE and the message “Number of tasks [%d] exceed the memcpy task max_tasks_num capability”, then fixes the pool with doca_dma_task_memcpy_set_conf(resources->dma_ctx, dma_memcpy_completed_callback, dma_memcpy_error_callback, num_tasks).[4] The buffer pool is fixed the same way: total_num_buf is source plus destination segments per task times tasks, and it becomes doca_buf_inventory_create(max_bufs, ...).[4]
The Comch fast path shows what to do when the work exceeds the pool. The producer thread caps its pool at MAX_FASTPATH_TASKS, noting “If requested messages exceeds maximum tasks, tasks will be resubmitted in their completion callback”, and calls doca_comch_producer_task_send_set_conf(producer, send_task_completed_callback, send_task_fail_callback, total_tasks).[6] The consumer’s completion callback resubmits in place: it takes the buffer with doca_comch_consumer_task_post_recv_get_buf(task), resets it with doca_buf_reset_data_len(buf) “so that it can be fully repopulated”, and calls doca_task_submit on the same task.[6] The PE README lists the benefits of that pattern: no free and allocate per iteration, a smaller task pool, and the ability to set new source or destination buffers before resubmitting.[2]
Backpressure is the other side of fixed pools. The producer’s submit loop reads “May need to wait for a post_recv message before being able to send” and spins on while (result == DOCA_ERROR_AGAIN) result = doca_task_submit(...).[6] DOCA_ERROR_AGAIN is the one code a retry loop is designed for.
4Completion, error and flush
“A DOCA task can invoke a success or error callback. Both callbacks share the same structure”, and DOCA recommends two: the success callback “does not need to check the task status, thereby improving performance”, the error callback “may need to run a different flow”.[2] In dma_common.c the success callback writes *result = DOCA_SUCCESS, frees the task with doca_task_free(doca_dma_task_memcpy_as_task(dma_task)), decrements num_remaining_tasks, and calls doca_ctx_stop(resources->state.ctx) when the count reaches zero; the error callback does the same after *result = doca_task_get_status(task) and a log of doca_error_get_descr(*result).[4]
Stopping delivers the rest. The state callback’s comment for DOCA_CTX_STATE_STOPPING says doca_pe_progress() “will cause any inflight task to be flushed” and will “eventually transition the context to idle state”, where the sample sets run_pe_progress = false.[4] The pe_async_stop sample makes the point that a stop “is asynchronous because the context must complete/abort all tasks” and that the error callback must “check if this is a real error or if the task is flushed”.[2] secure_channel codes that check explicitly: its consumer failure callback comments “Task fail errors may occur if context is in stopping state - this is expect” and returns without marking an error once the transfer is complete.[6]
A genuine failure can start the same sequence from the other end. The Core guide: “Once a task fails, the context may transition to stopping state, in this state, the application has to progress all in-flight tasks until completion before destroying or restarting the context.”[1] The pe_task_error sample submits 255 tasks with one deliberately invalid and shows the mitigation: progress until every submitted task has been flushed.[2] Clean teardown then follows request_stop_ctx, which loops doca_pe_progress and doca_ctx_get_state until DOCA_CTX_STATE_IDLE when doca_ctx_stop returns DOCA_ERROR_IN_PROGRESS, and the rule that all contexts are destroyed before the PE.[7][2]
Episode 3 — Two numbers on the whiteboard
They leave with two measurements instead of an opinion: the same workload under pe_polling and under pe_event, CPU noted for each, latency noted for each. The network lead copies both down; the notebook page now has two columns and a heading. The architect keeps the one-core budget and takes the event loop; the perf engineer keeps polling in the latency-critical path, on a PE of its own, since one PE can host several contexts.[1]
What you say: “Arm the notification when you are paying for the core, poll when you are paying for the microseconds — and measure both on your workload.”
The live demo goes on the calendar for Thursday. At 02:10 that morning the operator calls: the rehearsal is hanging, and nothing is printing.
Lab
Pre-flight (read-only): /opt/mellanox/doca/tools/doca_caps --list-devs on the BlueField; record the PF address and confirm the DOCA version matches the host.
- Build
pe_pollingandpe_eventin the Arm devel container (samemesonandninjapattern as the no-hardware lab). Neither binary takes arguments — there is no--helpand no device flag. - Run
/tmp/build-pe/doca_pe_pollingwith no arguments — expect log lines reporting task completions and a final success. The sample selects the first DMA-capable device itself, so the PF address you recorded in pre-flight is only there to confirm a DMA-capable device exists. In a second terminal,top -p $(pgrep doca_pe_polling)— note the CPU percentage while it runs. - Run
/tmp/build-pe/doca_pe_eventthe same way, also with no arguments — expect the same completions withRegistering PE eventandRunning until all tasks are completein the log, and a visibly lower CPU percentage intop. Record both numbers; they are the evidence for the README’s trade-off statement. - Run
/tmp/build-dma/doca_dma_local_copy -p <pf> -nt 16 --sdk-log-level 60— expect sixteenDMA task was completed successfullylines followed byDMA context entered into stopping stateandDMA context has been stopped. Count the completion lines before the stopping line; there should be no error callbacks. - No rollback needed: all programs operate on process-private buffers and touch no device configuration.
- In the
devel-3.5.0-hostcontainer with the 3.5.0 samples cloned, rundiff samples/doca_common/pe_polling/pe_polling_sample.c samples/doca_common/pe_event/pe_event_sample.c | head -120— expect the epoll includes,register_pe_event, and therun_for_completionloop as the differences. The README says this diff is how the PE samples are meant to be read. grep -n "doca_pe_request_notification\|doca_pe_clear_notification\|doca_pe_get_notification_handle\|doca_pe_progress" samples/doca_common/pe_event/pe_event_sample.c— expect the drain loop before the arm and the clear before the next progress. Write the four-call sequence from memory, then compare.cd samples/doca_common/pe_event && meson /tmp/build-pe && ninja -C /tmp/build-pe && ls -l /tmp/build-pe/doca_pe_event— expect the binary. These PE samples take no command-line arguments and have no ArgP help:pe_event_main.cdoes(void)(argc); (void)(argv);and callsrun_pe_event_sample()directly, andpe_common.copens the device itself withopen_doca_device_with_capabilities(check_dev_dma_capable, &state->device).[8][9] If meson cannot finddoca-common, fixPKG_CONFIG_PATHas in lesson 3.1. Run the binary here and it ends atMatching device not foundfromsamples/common.c; that is the expected failure without hardware.[7]grep -n "num_remaining_tasks\|doca_ctx_stop\|doca_task_get_status" samples/doca_dma/dma_common.c— expect both callbacks decrementing the counter and stopping the context at zero, anddoca_task_get_statusonly in the error callback.grep -n "DOCA_ERROR_AGAIN\|doca_buf_reset_data_len\|resubmit" applications/secure_channel/secure_channel_core.c— expect the producer retry loop and the consumer’s reset-and-resubmit in its completion callback.- Using the TaskLifecycleStepper above, switch
peModeto event and note at which step the thread sleeps and which call must precede the sleep.
Retrieval check
9 questions from memory. Answer before looking anything up; misses become flashcards.
Explain it to a Dell SE
Explain to a developer at a Dell customer, in five sentences, the difference between polling and event-driven use of the progress engine and when you would recommend each.
Sources
Facts in this lesson were checked against DOCA 3.5.0 Core programming guide and doca-samples tag 3.5.0, 2026-09-06. Dates are when each page was fetched.
- DOCA Core programming guide · fetched 2026-09-06 · DOCA 3.5.0
- samples/doca_common/README.md (progress engine samples) · fetched 2026-09-06 · DOCA 3.5.0
- samples/doca_common/pe_event/pe_event_sample.c (tag 3.5.0) · fetched 2026-09-06 · DOCA 3.5.0
- samples/doca_dma/dma_common.c (tag 3.5.0) · fetched 2026-09-06 · DOCA 3.5.0
- dma_local_copy_sample.c (tag 3.5.0) · fetched 2026-09-06 · DOCA 3.5.0
- applications/secure_channel/secure_channel_core.c (tag 3.5.0) · fetched 2026-09-06 · DOCA 3.5.0
- samples/common.c (tag 3.5.0) · fetched 2026-09-06 · DOCA 3.5.0
- samples/doca_common/pe_event/pe_event_main.c (tag 3.5.0) · fetched 2026-09-07 · DOCA 3.5.0
- samples/doca_common/pe_common.c (tag 3.5.0) · fetched 2026-09-07 · DOCA 3.5.0
The same idea elsewhere
Other lessons that cover this ground, sometimes from another course's angle.