Skip to content

Tasks, the progress engine, and completion

S3·E3One core, and they want it back · NVIDIA briefing room, design review, week four of six

S3·E3Analyze~35 minsources checked todayverified against DOCA 3.5.0 Core programming guide and doca-samples tag 3.5.0, 2026-09-06

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 situation · NVIDIA briefing room, design review, week four of six

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]

Task lifecycle · DOCA DMA · memcpy task
application owns the taskcontext / PE owns the taskdoca_task_submit ↓ ownership → ctx↑ callback: ownership → apptask poolnum_tasks, pre-sized*_alloc_initapp ownsset paramsuser_data, bufsin flighthardwaredoca_pe_progresspolled in a loopcallbacksuccess | errordoca_task_freeor resubmit
Step 1 / 7owner: pool
Zero-allocation data path

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.

1. Prerequisites (context Running)
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.

source

DMA engine in polling mode: step a task from alloc_init through submit, hardware execution, the callback inside doca_pe_progress and free. Toggle peMode to event to see where the wait moves.

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

How it ended

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.

  1. Build pe_polling and pe_event in the Arm devel container (same meson and ninja pattern as the no-hardware lab). Neither binary takes arguments — there is no --help and no device flag.
  2. Run /tmp/build-pe/doca_pe_polling with 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.
  3. Run /tmp/build-pe/doca_pe_event the same way, also with no arguments — expect the same completions with Registering PE event and Running until all tasks are complete in the log, and a visibly lower CPU percentage in top. Record both numbers; they are the evidence for the README’s trade-off statement.
  4. Run /tmp/build-dma/doca_dma_local_copy -p <pf> -nt 16 --sdk-log-level 60 — expect sixteen DMA task was completed successfully lines followed by DMA context entered into stopping state and DMA context has been stopped. Count the completion lines before the stopping line; there should be no error callbacks.
  5. No rollback needed: all programs operate on process-private buffers and touch no device configuration.

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.

11 flashcards for this lesson — 0 in deck. Spaced review lives at /review.

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.

  1. DOCA Core programming guide · fetched 2026-09-06 · DOCA 3.5.0
  2. samples/doca_common/README.md (progress engine samples) · fetched 2026-09-06 · DOCA 3.5.0
  3. samples/doca_common/pe_event/pe_event_sample.c (tag 3.5.0) · fetched 2026-09-06 · DOCA 3.5.0
  4. samples/doca_dma/dma_common.c (tag 3.5.0) · fetched 2026-09-06 · DOCA 3.5.0
  5. dma_local_copy_sample.c (tag 3.5.0) · fetched 2026-09-06 · DOCA 3.5.0
  6. applications/secure_channel/secure_channel_core.c (tag 3.5.0) · fetched 2026-09-06 · DOCA 3.5.0
  7. samples/common.c (tag 3.5.0) · fetched 2026-09-06 · DOCA 3.5.0
  8. samples/doca_common/pe_event/pe_event_main.c (tag 3.5.0) · fetched 2026-09-07 · DOCA 3.5.0
  9. 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.