Repository navigation
Description
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()intocurrentEnvironmentand ships it inTaskHostTaskCompleteafter every task (OutOfProcTaskHostNode.RunTask), serialized inTaskHostTaskComplete.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
PROTOTYPE: skip redundant per-task env apply/restore in task host + m…
Measure: replace ConfigByteMeter with issue author's ThProfile instru…
TEMP: instrument task-host IPC with ThProfile for dotnet#14097 baseli…
Fix forward env-delta aliasing the live multithreaded environment dic…
Fix forward env-delta aliasing the live multithreaded environment dic…
Make task-host environment reuse unconditional; remove the opt-out tr…
Make task-host environment reuse unconditional; remove the opt-out tr…
Measure: add exact cfg_solutionConfig (CurrentSolutionConfigurationCo…
Measure: add exact cfg_solutionConfig (CurrentSolutionConfigurationCo…
ccastanedaucf commentedon Jun 29, 2026
FYI this just came up on my feed, I assume this is the
ImmutableDictionary/ task item dictionary stuff? I have a prototype somewhere that reimplementsImmutableDictionarywith 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.ImmutableDictionaryis 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.
JanProvaznik commentedon Jun 30, 2026
@ccastanedaucf https://github.com/OrchardCMS/OrchardCore/tree/main
dotnet build -mteither msbuild main or dotnet 11 daily
Send task-host build process environment as delta (#14126)