Skip to content

Preserve configured scheduler for queued TaskFlow callbacks - #60

Draft
dombrovsky wants to merge 1 commit into
mainfrom
fix/48-preserve-task-scheduler
Draft

dombrovsky wants to merge 1 commit into
mainfrom
fix/48-preserve-task-scheduler

Conversation

@dombrovsky

Copy link
Copy Markdown
Owner

Queued TaskFlow callbacks now enter through the configured TaskScheduler after an incomplete predecessor. This is a correctness fix for 1.1.0, referencing #48; the issue remains open.

The old RunAfterPrevious awaited the predecessor with ConfigureAwait(false), then called the user delegate from that continuation. FIFO survived, but scheduler identity did not. Pending operations now use an unconditional ContinueWith on Options.TaskScheduler, followed by the existing Unwrap and completion tail. Completed predecessors retain StartNew and its synchronous rejection behavior. Predecessor faults are observed without propagating them to later operations. There is no extra scheduling task per operation, public API change, Polly change, or change to the final await taskFunc(...).ConfigureAwait(false).

ConfigureAwait(true) was tested and is insufficient: a scheduler-installed non-default SynchronizationContext wins over scheduler capture. The regression context posts raw callbacks onto the same physical worker thread, where TaskScheduler.Current is the default scheduler. Explicit task scheduling preserves callback-entry scheduler identity while leaving user awaits alone: capturing user code follows normal context/scheduler precedence; ConfigureAwait(false) can escape both. Scheduler identity, physical thread, synchronization context, and execution context remain distinct.

The new fixture contains 25 deterministic cases using a non-inlining single-thread scheduler, gates, and queue markers. Coverage includes completed/pending successful, faulted, and canceled operations; FIFO and logical non-overlap; callback placement and capturing/escaping awaits; context precedence; operation-specific and suppressed ExecutionContext; caller cancellation, asynchronous shutdown and zero-timeout synchronous disposal; scheduler rejection/disposal and subsequent recovery; non-reentrant entry; and the adapter's independent scheduling contract. Existing dedicated/current-thread, execution-context, middleware, and disposal conformance tests also ran in the full suite.

Failure evidence before the fix

With production code restored to the original implementation, the final PendingPredecessor_CallbackEntersConfiguredScheduler regression failed both cases on each of net8.0 and net10.0:

callback-entry scheduler
Expected: same as <SingleThreadScheduler>
But was: <System.Threading.Tasks.ThreadPoolTaskScheduler>

A separate production-code experiment changing only the predecessor await to ConfigureAwait(true) on .NET 10 passed the case without a synchronization context and failed the case with one. The final fix passes all 25 cases on both targets. No sleeps or timing-based scheduling assumptions are used in the new fixture.

Validation

  • dotnet restore TaskFlow.sln: passed.
  • dotnet build TaskFlow.sln -c Release --no-restore: passed, zero warnings/errors; includes netstandard2.0, net8.0, and net10.0 library builds.
  • dotnet test TaskFlow.Tests/TaskFlow.Tests.csproj -c Release --no-build --filter FullyQualifiedName~TaskFlowSchedulerPlacementFixture: 25/25 passed per target, 50 total.
  • dotnet format whitespace TaskFlow.sln --no-restore, its --verify-no-changes check, and dotnet format style TaskFlow.sln --diagnostics IDE0161 --verify-no-changes --no-restore: passed. Format reports the existing shared .shproj workspace-load warning.
  • git -c core.whitespace=cr-at-eol diff origin/main --check: passed. C# edits use the repository's CRLF convention; existing Markdown line endings are preserved.

Full Release solution runs completed on both test targets. The existing full suite is not green. Core-project results:

Run .NET 8 core tests .NET 10 core tests
Patched, standard solution run 2,465 passed / 5 failed 2,462 passed / 8 failed
Unmodified main (15e3d31) 2,443 passed / 2 failed 2,437 passed / 8 failed
Patched, serialized (-m:1 -p:TestTfmsInParallel=false) 2,463 passed / 7 failed 2,464 passed / 6 failed

Every failing test name across the patched runs was also observed failing on unmodified main (full-suite and focused baseline runs). The failures occur in CancellationScopeTaskSchedulerExtensionsFixture and CancelPreviousTaskSchedulerExtensionsFixture, with existing 100 ms polling assertions observing WaitingForActivation; counts vary by run. The .NET 10 standard full run has the exact same eight failing test names on main and this branch. This is evidence of existing failures, not a claim that the full suite passed.

All other core tests passed, including the dedicated/current-thread and adapter fixtures and the new regressions. Dependency-injection (30), logging (5), and time (70) tests all passed on each target in all full runs. There were no skipped tests. SDK: 10.0.403; test runtimes: .NET 8.0.0 and 10.0.14.

Full-run commands were dotnet test TaskFlow.sln -c Release --no-build on this branch, dotnet test TaskFlow.sln -c Release --no-restore in an isolated main worktree, and the serialized command above on this branch. TRX logging was enabled for each run.

Compatibility and remaining risks

Pending operations are dispatched after predecessor completion. A scheduler rejecting that deferred dispatch now faults the operation's returned task with the original failure inside TaskSchedulerException; it cannot silently run the callback on the thread pool. This also means rejection for a pending predecessor is delivered asynchronously, whereas rejection of the completed-predecessor StartNew remains synchronous. Keep the scheduler available through flow disposal. Cancellation remains cooperative, and each accepted delegate still receives its linked token when invoked. A user's explicit context escape is respected.

The remaining validation limitation is the existing cancellation-test failures described above. This PR stays draft while the full-suite baseline is not green; it does not change unrelated middleware or timing-based tests.

dombrovsky commented Nov 1, 2026 •

Copy link
Copy Markdown
Owner Author

Superseded by the follow-up investigation. The large single-launch percentages below did not reproduce in stronger measurements. No performance tuning change was published; the possible smaller async cost remains unresolved.

Recommendation: hold performance clearance for this head before 1.1.0 stable; investigate or explicitly accept the queued-throughput cost before merging. Production code and benchmark infrastructure are unchanged.

Compared bf051a69e65f85547d6a2aaf44f9aab5b5e590d2 against parent 15e3d31ce9f81c63a2dee3e33d7b4df4257a9da0 using the existing same-run comparison script, full scope, twice: all 16 cases succeeded on each side in both pairs. Same AMD EPYC 9V45 managed runner, .NET SDK/runtime 10.0.100 / 10.0.0, BDN 0.15.8, two-CPU quota, three warmups/eight measurements/one launch. Baseline ran before candidate.

The complete repeat, with affinity fixed to CPUs 0–1 for both revisions, flags three material slowdowns under the repository's advisory heuristic:

Case Time ns/op: baseline → head; change Throughput ops/s: baseline → head; change Managed B/op: baseline → head; change
Burst, 1,000-operation drain 317.42 → 485.61; +168.19 (+53.0%) 3,150,405 → 2,059,258; -1091147.18 (-34.6%) 584 → 628; +44.00 (+7.5%)
Four producers, 1,000-operation drain 288.71 → 602.35; +313.64 (+108.6%) 3,463,716 → 1,660,170; -1803546.40 (-52.1%) 689 → 665; -24.00 (-3.5%)
Sequential async, live token 1538.82 → 2038.28; +499.46 (+32.5%) 649,849 → 490,609; -159239.68 (-24.5%) 728 → 730; +2.00 (+0.3%)

Variability: the first pair (affinity 0–2) classified all timing differences as inconclusive. The fixed-affinity signals above survive restoring excluded samples: all-eight-iteration time increases are 50.7%, 58.5%, 33.0% respectively. Retained samples range from 6–8. The reporter's CV/three-standard-error checks and 20% materiality threshold are advisory, not formal significance tests. One launch, baseline-first drift, shared-host load/power, and denied high priority limit attribution and precision.

Other focus areas: synchronous sequential means rise 10.6–12.1%, but are inconclusive. Async/no-token shows a smaller lower-variance +152.50 ns (+8.1%), throughput −7.5%. Enqueue-only improves in mean by 161.17 ns (−46.3%), submissions/s +86.1%, but candidate CV is 26%: not a reliable speedup, and allocations are unsupported. Other producer/middleware timings are inconclusive.

All 16 fixed-affinity results: absolute and percentage time, throughput, and allocation changes
Case Baseline ns/op ± SD Candidate ns/op ± SD Δ ns/op (%) Baseline / candidate ops/s Δ ops/s (%) Baseline / candidate bytes/op Δ bytes/op (%) Evidence
BurstBenchmarks.SubmitAndDrain 317.42 ± 10.02 485.61 ± 61.69 +168.19 (+53.0%) 3150405.06 / 2059257.88 -1091147.18 (-34.6%) 584 / 628 +44.00 (+7.5%) material slowdown (advisory)
EnqueueOnlyBenchmarks.SubmitOnly 348.34 ± 41.36 187.17 ± 48.67 -161.17 (-46.3%) 2870778.47 / 5342695.77 +2471917.30 (+86.1%) unsupported / unsupported unsupported insufficient evidence: variability / overlapping uncertainty
MiddlewareBenchmarks.PipelineCompletion(Layers: 0) 1497.13 ± 294.91 1730.54 ± 375.09 +233.41 (+15.6%) 667942.54 / 577853.64 -90088.91 (-13.5%) 512 / 521 +9.00 (+1.8%) insufficient evidence: variability / overlapping uncertainty
MiddlewareBenchmarks.PipelineCompletion(Layers: 1) 2531.73 ± 615.04 2079.13 ± 795.48 -452.59 (-17.9%) 394987.53 / 480969.46 +85981.93 (+21.8%) 1604 / 1604 +0.00 (+0.0%) insufficient evidence: variability / overlapping uncertainty
MiddlewareBenchmarks.PipelineCompletion(Layers: 2) 3093.23 ± 94.19 3167.06 ± 87.29 +73.84 (+2.4%) 323286.80 / 315749.79 -7537.01 (-2.3%) 2106 / 2108 +2.00 (+0.1%) insufficient evidence: variability / overlapping uncertainty
MiddlewareBenchmarks.PipelineCompletion(Layers: 4) 3046.52 ± 553.69 3590.90 ± 108.50 +544.38 (+17.9%) 328242.96 / 278481.76 -49761.20 (-15.2%) 3118 / 3116 -2.00 (-0.1%) insufficient evidence: variability / overlapping uncertainty
MiddlewareBenchmarks.PipelineCompletion(Layers: 8) 3879.13 ± 736.84 3793.61 ± 72.01 -85.53 (-2.2%) 257789.44 / 263601.20 +5811.76 (+2.3%) 5132 / 5130 -2.00 (-0.0%) insufficient evidence: variability / overlapping uncertainty
ProducerBenchmarks.ConcurrentSubmitAndDrain(Producers: 1) 414.89 ± 164.95 468.82 ± 17.89 +53.93 (+13.0%) 2410250.89 / 2133013.93 -277236.96 (-11.5%) 588 / 593 +5.00 (+0.9%) insufficient evidence: variability / overlapping uncertainty
ProducerBenchmarks.ConcurrentSubmitAndDrain(Producers: 16) 687.38 ± 123.29 415.93 ± 21.51 -271.45 (-39.5%) 1454802.82 / 2404243.10 +949440.28 (+65.3%) 664 / 666 +2.00 (+0.3%) insufficient evidence: variability / overlapping uncertainty
ProducerBenchmarks.ConcurrentSubmitAndDrain(Producers: 4) 288.71 ± 6.10 602.35 ± 63.19 +313.64 (+108.6%) 3463716.32 / 1660169.92 -1803546.40 (-52.1%) 689 / 665 -24.00 (-3.5%) material slowdown (advisory)
SequentialBenchmarks.AsyncCompletion(Token: CanceledCooperative) 1954.84 ± 214.04 1677.66 ± 658.52 -277.19 (-14.2%) 511549.66 / 596068.89 +84519.23 (+16.5%) 728 / 727 -1.00 (-0.1%) insufficient evidence: variability / overlapping uncertainty
SequentialBenchmarks.AsyncCompletion(Token: Live) 1538.82 ± 179.85 2038.28 ± 24.62 +499.46 (+32.5%) 649848.54 / 490608.87 -159239.68 (-24.5%) 728 / 730 +2.00 (+0.3%) material slowdown (advisory)
SequentialBenchmarks.AsyncCompletion(Token: None) 1879.91 ± 33.68 2032.41 ± 52.92 +152.50 (+8.1%) 531939.51 / 492026.10 -39913.41 (-7.5%) 712 / 716 +4.00 (+0.6%) below 20% reporting heuristic; advisory
SequentialBenchmarks.SyncCompletion(Token: CanceledCooperative) 1520.60 ± 422.49 1694.71 ± 158.71 +174.11 (+11.4%) 657633.15 / 590070.76 -67562.39 (-10.3%) 528 / 538 +10.00 (+1.9%) insufficient evidence: variability / overlapping uncertainty
SequentialBenchmarks.SyncCompletion(Token: Live) 1237.05 ± 350.92 1386.85 ± 452.57 +149.80 (+12.1%) 808375.30 / 721060.60 -87314.70 (-10.8%) 528 / 549 +21.00 (+4.0%) insufficient evidence: variability / overlapping uncertainty
SequentialBenchmarks.SyncCompletion(Token: None) 1408.14 ± 36.22 1557.29 ± 278.70 +149.15 (+10.6%) 710154.78 / 642140.14 -68014.64 (-9.6%) 512 / 530 +18.00 (+3.5%) insufficient evidence: variability / overlapping uncertainty

Reproduction: python3 build/benchmark_compare.py --mode merge-base --target 15e3d31ce9f81c63a2dee3e33d7b4df4257a9da0 --candidate bf051a69e65f85547d6a2aaf44f9aab5b5e590d2 --scope full --output <new-directory>. Repeat prefixes the command with taskset -c 0,1. The existing contract excludes keyed/admission cases. Each pair used independent checkouts and matching harness/environment-series hashes, with no concurrent builds/tests. Different affinity series were not pooled. Actions dispatch was unavailable; these are local measurements.

Burst timing CVs: 3.2% / 12.7%; four producers: 2.1% / 10.5%; async/live: 11.7% / 1.2%. The four-producer baseline excludes two slow samples, making its retained-sample +108.6% estimate larger than its raw +58.5%. Raw sensitivity checks include all eight workload iterations without BDN's tiny overhead subtraction. The first, noisy pair also showed adverse changes in those cases: +39.4%, +137.6%, +22.5% respectively. MemoryDiagnoser allocation deltas have no reported uncertainty; treat them as observed managed bytes/op, not statistical findings. No enqueue-only allocation claim is valid.

Provenance: harness SHA-256 f1fe135c8478889cb1e8debfb957fc395949377756770326cb8f2306141e0e36; first-pair series 33d66e356270328f19ed8bfdbd7ea4f620dce0bdc6cd83b031d6b59ad8f9eca3; fixed-affinity series 7d4cc9705d45b0bea74d94ca80bea9c6d07cc0889363b24006499091bbb1e2d7. Raw JSON, measurements, and provenance are retained in the workspace under work/performance-pr60/ and work/performance-pr60-affinity/.

Copy link
Copy Markdown
Owner Author

Investigation update — supersedes my earlier performance assessment. The reported +53% burst / +109% four-producer increases are not established code regressions. The stronger measurements did not reproduce those magnitudes. I have not isolated a confirmed cause for the remaining possible async cost or validated a production fix, so PR #60 remains unchanged at bf051a69e65f85547d6a2aaf44f9aab5b5e590d2.

What I established:

  • The implementation does change queue placement: an awaited predecessor can resume inline at a valid pool location; PR Preserve configured scheduler for queued TaskFlow callbacks #60 explicitly schedules pending callbacks. That is a plausible cost, not proof that it caused the earlier percentages.
  • A full 16-case A/A control, measuring the exact same parent commit twice, changed burst mean from 523.97 to 360.60 ns/op (−31.2%) with no source change. All 16 control comparisons were inconclusive. Process-to-process/shared-runner variation is substantial; within-launch error bars do not capture it fully.
  • A direct parent/head confirmation used the existing, unchanged harness with three launches × twenty measured iterations, SDK/runtime 10.0.100 / 10.0.0, the same two-CPU quota and fixed affinity 0–1. It covered the five affected cases below. The original full 16-case comparisons were retained; this was additional targeted confirmation, not a replacement for full scope.
Case Parent → PR mean ns/op Change Interpretation
Burst submit/drain 409.74 → 429.53 +19.79 (+4.8%) inconclusive on this runner
Enqueue-only submission 234.48 → 171.18 -63.30 (-27.0%) inconclusive on this runner
Four-producer submit/drain 629.11 → 653.82 +24.71 (+3.9%) inconclusive on this runner
Sequential async, no token 1518.20 → 1776.42 +258.22 (+17.0%) inconclusive on this runner
Sequential async, live token 1622.59 → 1751.47 +128.88 (+7.9%) inconclusive on this runner

The +17% async/no-token observation remains a possible smaller regression; I am not claiming it disappeared. Three launches, baseline-first drift, and uncontrolled host scheduling/power are insufficient to attribute it confidently or certify release performance. The burst launch-mean ranges overlap (parent 384–443 vs PR 395–447 ns/op), as do four-producer ranges (610–677 vs 622–682 ns/op). Gated enqueue-only measurements had many excluded outliers; its apparent improvement is not a dependable speedup.

I tested three isolated changes: restoring the original default-scheduler wait; allowing local pool queues for pending callbacks; and inlining only internal completion bookkeeping while preserving the suppressed-ExecutionContext boundary. They passed the 31-case scheduler regression fixture on both .NET 8 and 10, but none consistently improved the three-launch results. Internal-marker inlining also worsened the four-producer mean by 33.4% in its confirmation. All three remain unpublished experimental branches; no production or benchmark-infrastructure change is being proposed from this investigation.

Recommendation: I withdraw the earlier categorical performance-hold rationale based on the large single-launch percentages. This is not unconditional merge/performance clearance: the smaller async observation and the already documented full-suite baseline failures remain open. If performance is a stable-release gate, obtain controlled-runner, multi-launch paired evidence before choosing a code change. The repository's existing harness already supports that; no new benchmark infrastructure is needed.

Absolute and percentage throughput/allocation results for the stronger parent/head confirmation

Throughput is the reciprocal of the amortized mean, not an independent measurement. Managed allocation figures have no reported uncertainty; enqueue-only allocation attribution is unsupported. Three warmups and twenty actual iterations per launch; three launches per side. Outlier filtering retained 50–60 of 60 measurements; those are not sixty independent process launches.

Case Parent → PR ops/s Change ops/s (%) Parent → PR managed B/op Change B/op (%)
Burst submit/drain 2,440,577 → 2,328,128 -112449.72 (-4.6%) 595 → 622 +27.00 (+4.5%)
Enqueue-only submission 4,264,692 → 5,841,812 +1577120.25 (+37.0%) unsupported unsupported / missing
Four-producer submit/drain 1,589,548 → 1,529,483 -60064.89 (-3.8%) 677 → 655 -22.00 (-3.2%)
Sequential async, no token 658,676 → 562,931 -95744.49 (-14.5%) 712 → 707 -5.00 (-0.7%)
Sequential async, live token 616,300 → 570,950 -45349.68 (-7.4%) 728 → 722 -6.00 (-0.8%)
Experimental changes: three-launch time changes relative to the unchanged PR head

Each experiment used the same four-case confirmation and matched settings within its pair. Do not pool different comparison runs. These figures explain why I did not ship a speculative tuning patch.

Experiment Burst Four producers Async / no token Async / live token
Restore original pool wait +23.7% -2.2% -3.9% +4.1%
Allow local pending-callback queue -2.5% -5.7% -1.6% +1.4%
Inline internal completion marker -8.0% +33.4% -14.8% -6.8%

All full-scope results, A/A results, raw BDN JSON, per-launch measurements and provenance are retained in work/performance-pr60-*. The stronger direct original comparison is in work/performance-pr60-original-multilaunch/. Harness SHA-256: f1fe135c8478889cb1e8debfb957fc395949377756770326cb8f2306141e0e36. These are local managed-runner measurements; Actions dispatch was unavailable.

Copy link
Copy Markdown
Owner Author

Profiling follow-up for 15e3d31 → bf051a6: a small allocation regression is confirmed; the previously reported large time regressions are still unproven. No production changes were made.

This is focused profiling of four sensitive cases (sequential async/no token, burst, four producers, and enqueue-only). The earlier benchmark comparison covered all 16 compatible cases. Both revisions used the identical existing common harness (f1fe135c…), SDK 10.0.100/runtime 10.0.0, Release, DOTNET_PROCESSOR_COUNT=2, and affinity CPUs 0–1 on this runner.

What the profiles show

  • Allocation cause: pending ContinueWith creates a 40-byte ContinueWithTaskContinuation; its continuation-result task is 80 bytes versus the old 72-byte startup task (.NET 10 x64). That is 48 bytes gross per deferred operation, partly offset by removal of predecessor-await state-machine allocations and other timing-dependent differences. In the earlier 3-launch/20-iteration comparison, burst allocation rose 595 → 622 B/op (+27 B, +4.5%). This follow-up's ThreadingDiagnoser run measured 541 → 587 B/op (+46 B, +8.5%). Independent GC allocation sampling over 4,096,000 actual operations per revision estimated 575.9 → 609.1 B/op (+33.3 B, +5.8%); those sampled byte estimates are approximate. The object types/sizes establish the cause independently of the sampled totals.
  • Queue placement, not extra work items: in a diagnostic 1,000-operation burst, both revisions scheduled/executed 2,000 tasks. The parent submitted 1,001 from the controller and 999 from workers; head submitted 2 and 1,998 respectively. The parent queues startup work up front and can resume it inline after predecessor completion. Head queues each pending callback after predecessor completion, putting that dispatch on the serial path. This is a plausible latency cost, not a quantified explanation of the earlier slowdown percentages. The short task trace is structural evidence, not a speed benchmark.
  • Existing ThreadingDiagnoser counters corroborate:
Workload Completed pool work items/op: parent → head Lock contentions/op: parent → head
Burst 2.0000 → 2.0000 0 → 0
Four producers 2.0045 → 2.0041 0.0011 → 0.0005
Sequential async/no token 3.0000 → 3.0000 approximately 0 → approximately 0
Enqueue-only, predecessor gated 1.0000 → 0 0 → 0

Enqueue-only defers startup work until the gate opens; its allocation metric remains unsupported. Four-producer contention decreased in this run, so increased contention does not explain a slowdown here.

  • No evidence of a GC/cancellation bottleneck causing a large slowdown. In the allocation traces, GC pause totals were 37.2 → 42.9 ms across 4.096M operations (9.09 → 10.48 ns/op). This is diagnostic data, not an isolated GC performance comparison. Sequential allocation remained approximately 712 B/op in the threading runs.

Variability and limits

Collected 8 EventPipe stack traces, 4 short TPL task traces, and 2 GC/allocation traces; all report zero lost events, and stack traces report zero missing sampled stacks. Analysis excludes startup/pilot/warmup and uses actual-workload markers. EventPipe samples include parked threads: these are not CPU-utilization percentages, and kernel CPU/context-switch traces were not collected. Enqueue-only has only 32/45 sampled stacks, insufficient for ranking small hotspots. Instrumented elapsed times are not acceptance benchmarks.

Thread-pool hill climbing changes worker targets during measurement even with processor count fixed at 2 (sequential traces: parent 2–5, head 2–8). This demonstrates differing pool state, but does not prove how much of the timing variance it causes. The earlier parent-vs-parent burst comparison also moved −31.2% with no code change. A smaller sequential latency cost remains possible; neither the original +53%/+109% claims nor a precise replacement timing penalty is established.

Fix/merge recommendation: keep the explicit custom-scheduler dispatch required by #48. A Default-scheduler-only return to the original wait path can avoid the new allocation/dispatch pattern; the earlier prototype reduced burst allocation 582 → 536 B/op, but did not establish a timing improvement (its burst timing was worse in that noisy comparison). Do not merge that speculative optimization, inline user callbacks, or replace this with ConfigureAwait(true). Profiling supports retaining the focused correctness fix with the small, explicit allocation trade-off; it does not establish a material throughput regression that warrants a scheduling rewrite. It also does not provide an unconditional performance clearance. The separately documented existing full-suite failures remain unresolved, so retain the draft/release hold pending their disposition.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant