Skip to content

Task host IPC ships the full environment in every config and echoes it back in every result (~21% of -mt task-host traffic is redundant) #14097

Closed

Description

@JanProvaznik

Summary

In -mt (multi-threaded) builds — and for the Runtime="NET" task host in general — every task that runs out-of-process ships the full build process environment (buildProcessEnvironment) to the task host in every TaskHostConfiguration, and the task host echoes the full environment back in every TaskHostTaskComplete. The environment is essentially invariant across all tasks in a build, so this is the same ~6 KB block transferred twice per task, thousands of times.

Profiling an OrchardCore -mt Rebuild (10,392 out-of-proc task dispatches) shows this redundant environment is ~21% of all task-host IPC bytes. This issue proposes sending the environment once per connection and then only a per-task delta (usually empty).

Measured data

Single Windows dev box, OrchardCore.Cms.Web -t:Rebuild -mt, bootstrap MSBuild, 10,392 out-of-proc task dispatches. Byte breakdown is deterministic (identical across runs); phase times are 3-run medians, summed across all task hosts. Captured via lightweight file-dump instrumentation around TaskHostConfiguration.Translate / TaskHostTaskComplete.Translate and the child execution phases.

TaskHostConfiguration — parent → child, 453.7 MB total (avg 45.8 KB/config):

field size % of config redundant?
taskParameters (task inputs, e.g. Csc sources/refs) 383.3 MB 84.5% no — unique payload
buildProcessEnvironment 63.0 MB 13.9% yes — invariant across tasks
other (paths, names, cultures, line/col) 5.2 MB 1.1% partly
warningsAs* 1.9 MB 0.4% yes
globalParameters 0.3 MB 0.1% yes

TaskHostTaskComplete — child → parent, 150.5 MB total (avg 15.2 KB/result):

field size % of result redundant?
taskOutputParameters 87.5 MB 58.1% no — real outputs
buildProcessEnvironment (echoed back) 63.0 MB 41.8% yes — near-always unchanged

Combined: 63.0 MB (forward) + 63.0 MB (back) = 126 MB ≈ 21% of the 604 MB of total task-host traffic is the same environment, reshuffled 10,392×.

Where the round-trip time actually goes (summed across hosts, 3-run median ms):

phase ms nature
execute (real task work) 358,000 the work
config receive (cfg_deserialize) 165,000 transfer-bound*
result receive 10,300 transfer-bound
env capture + restore (2× SetEnvironment) 5,700 CPU
env apply 5,200 CPU
param set (reflection) 3,500 CPU
config serialize 2,400 CPU
result serialize 1,000 CPU
output get (reflection) 800 CPU

* Note: (de)serialization itself is not a CPU bottleneck. Serialize into the in-memory packet stream is ~0.22 ms/config (2.4 s summed). The "deserialize" timer reads ~12–18 ms/config, but the child deserializes inline off the pipe, so that number is ~3 MB/s = the pipe transfer rate, i.e. it is receive/transfer time, not CPU. The redundant environment is therefore a bytes-on-the-wire problem, not a parsing problem.

Root cause (code)

  • Forward copy: TaskHostConfiguration.Translate → translator.TranslateDictionary(ref _buildProcessEnvironment, ...).
  • Return copy: the task host captures CommunicationsUtilities.GetEnvironmentVariables() into currentEnvironment and ships it in TaskHostTaskComplete after every task (OutOfProcTaskHostNode.RunTask), serialized in TaskHostTaskComplete.Translate → translator.TranslateDictionary(ref _buildProcessEnvironment, ...).

The return copy exists so the parent can observe environment variables a task mutated. In practice the common tasks routed to the task host (Csc, GenerateDepsFile, ResolvePackageAssets, …) do not mutate the environment, so the returned dictionary is almost always identical to what was sent.

Proposed change

Establish the environment once per task-host connection (at connection/first-config), then per task send only a delta against the last-known environment for that connection (added/changed/removed keys), in both directions. Normal case = empty delta.

This is complementary to (and stacks with) the named-pipe buffer change in #14094; that one makes each byte cheaper to move, this one removes ~21% of the bytes.

Estimated impact

The unique payload (taskParameters 84.5% of config, taskOutputParameters 58% of result) is irreducible, so this is a bounded win. Measured env-receive time is ~57 s summed, but that over-attributes per-config arrival latency; the byte-proportional marginal saving is ~27 s summed → ÷ measured average dispatch concurrency (5.9) ≈ ~4.6 s of critical-path → ~2–4% wall on a ~110 s build, with the usual caveat that much task-host transfer overlaps with execution on other hosts. Modest but real, low-risk, and it also cuts allocations and non-paged pool pressure.

Correctness — must work with the Runtime="NET" (.NET) task host

Details to be investigated and clarified, initial agentic discussion was incorrect

Methodology / reproduction

Instrumentation recorded per-field byte sizes (write side, seekable MemoryStream) and per-phase wall times via Stopwatch, dumped to a per-PID CSV at process exit, then aggregated across the build manager and all task-host processes. Workload: dotnet msbuild OrchardCore.Cms.Web.csproj -t:Rebuild -mt with node reuse disabled, 3 runs. Happy to share the instrumentation patch and raw CSVs.

Activity

  1. self-assigned this
    on Jun 18, 2026
  2. ccastanedaucf commented on Jun 29, 2026

    @ccastanedaucf
    Contributor

    The unique payload (taskParameters 84.5% of config, taskOutputParameters 58% of result) is irreducible

    FYI this just came up on my feed, I assume this is the ImmutableDictionary / task item dictionary stuff? I have a prototype somewhere that reimplements ImmutableDictionary with an array-backed version that serializes much, much faster (since it's just copying arrays instead of a giant object tree) and at a much smaller payload size. ImmutableDictionary is crazy inefficient for serialization since it requires rebuilding the entire structure on deserialization and each node is something ridiculous like 64 bytes. The Array Dictionary was originally just for RAR out-of-proc and binlogs, but even when I tested it back then, it got rid of most of the IPC overhead I was seeing at the time and made in-proc scenarios faster (there were just some edge cases I never got around to finishing).

    Is there a benchmark / test repo you guys are using to measure end-to-end time with specific flags? I want to run this through a profiling tool I'm working on and see if plugging in the array dictionary improves all of this.

  3. JanProvaznik commented on Jun 30, 2026

    @JanProvaznik
    MemberAuthor

    @ccastanedaucf https://github.com/OrchardCMS/OrchardCore/tree/main dotnet build -mt

    either msbuild main or dotnet 11 daily

  4. added a commit that references this issue on Jul 8, 2026
    27b36dc
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions