Skip to content

Measure task host overhead in mt - #13555

Merged
OvesN merged 11 commits into
dotnet:mainfrom
OvesN:dev/veronikao/measure-task-host-overhead-in-mt
Apr 23, 2026
Merged

OvesN merged 11 commits into
dotnet:mainfrom
OvesN:dev/veronikao/measure-task-host-overhead-in-mt

Conversation

@OvesN

@OvesN OvesN commented Apr 16, 2026

Copy link
Copy Markdown
Contributor

Fixes #13372

Context

In multi-threaded (MT) mode, tasks that are not marked with [MSBuildMultiThreadableTask] are routed to an out-of-proc task host process for isolation. This introduces overhead from IPC serialization, process communication, environment setup, assembly loading, and parameter marshaling - all on top of the actual task execution time.

This PR uses ETW instrumentation to measure that overhead and provides tooling to analyze the results.

Changes Made

Added ETW events to MSBuildEventSource (events 109–112)

Event ID Name Location What it measures
109 TaskHostDispatchStart TaskHostTask.Execute() (main process) Start of full task host roundtrip
110 TaskHostDispatchStop TaskHostTask.Execute() (main process) End of full roundtrip (includes IPC + remote execution)
111 TaskExecuteInHostStart OutOfProcTaskAppDomainWrapperBase (task host process) Start of actual task.Execute()
112 TaskExecuteInHostStop OutOfProcTaskAppDomainWrapperBase (task host process) End of actual task.Execute()

How the events map to the execution flow

Main process (dotnet.exe) — TaskHostTask.Execute():
  Event 109: TaskHostDispatchStart
    ├─ Serialize task configuration               — TaskHostTask builds TaskHostConfiguration packet
    ├─ Acquire/launch task host process            — NodeProviderOutOfProcTaskHost.AcquireAndSetUpHost()
    ├─ Send config via IPC pipe                    — NodeProviderOutOfProcTaskHost sends packet
    │
    │   Task host process (MSBuild.exe) — OutOfProcTaskHostNode.RunTask():
    │     ├─ Set environment, culture              — OutOfProcTaskHostNode sets up env
    │     ├─ Load task assembly                    — OutOfProcTaskAppDomainWrapperBase.ExecuteTask()
    │     ├─ Create task instance + set params     — TaskLoader.CreateTask()
    │     │
    │     Event 111: TaskExecuteInHostStart         (in OutOfProcTaskAppDomainWrapperBase)
    │     │  └─ wrappedTask.Execute()              ← actual work (e.g. Copy.Execute())
    │     Event 112: TaskExecuteInHostStop
    │     │
    │     ├─ Collect output parameters             — OutOfProcTaskAppDomainWrapperBase collects outputs
    │     └─ Send TaskHostTaskComplete via IPC     — OutOfProcTaskHostNode.CompleteTask()
    │
    ├─ Receive TaskHostTaskComplete packet         — TaskHostTask.HandleTaskHostTaskComplete()
  Event 110: TaskHostDispatchStop

How to compute overhead

Both Stop events include DURATION_MSEC (auto-calculated by ETW). For each task type.
Dispatch events (main process) and ExecuteInHost events (task host process) are in different processes on different threads — there is no shared ActivityID linking a specific dispatch to its corresponding execute. Instead, we aggregate durations per task type:

All Dispatch Stop events for "Copy" → sum durations → total dispatch time
All Execute Stop events for "Copy" → sum durations → total execute time
Copy overhead = total dispatch − total execute
Overhead % = overhead / total dispatch × 100

The script validates that dispatch and execute event counts match per task type. If counts differ, a warning is printed.

CSV column definitions (task_host_summary.csv)

Column How it's calculated
task_name Fully qualified task class name, extracted from taskName field in ETW event
dispatch_count Number of TaskHostDispatch/Stop events for this task type
execute_count Number of TaskExecuteInHost/Stop events for this task type. Should equal dispatch_count; If not equal it mean that event had a missing duration time.
total_dispatch_ms Sum of DURATION_MSEC from all TaskHostDispatch/Stop events — total wall-clock time in the main process (includes IPC + remote execution + result retrieval)
total_execute_ms Sum of DURATION_MSEC from all TaskExecuteInHost/Stop events — total time inside the task host running task.Execute()
total_overhead_ms total_dispatch_ms − total_execute_ms — time spent on IPC, serialization, environment setup, assembly loading, and parameter marshaling
avg_dispatch_ms total_dispatch_ms / dispatch_count — average dispatch roundtrip per invocation
avg_execute_ms total_execute_ms / execute_count — average actual task execution per invocation (uses its own count to avoid cross-dividing when counts differ)
avg_overhead_ms total_overhead_ms / dispatch_count — average overhead per invocation
overhead_pct total_overhead_ms / total_dispatch_ms × 100 — percentage of dispatch time that is overhead

How to Collect Measurements

1. Delete all [MSBuildMultiThreadableTask] from tasks

2. Build MSBuild with these changes

.\build.cmd 

3. Clean up previous build

D:\msbuild\artifacts\bin\bootstrap\core\dotnet.exe build-server shutdown
D:\msbuild\artifacts\bin\bootstrap\core\dotnet.exe clean

4. Collect ETW trace with PerfView

PerfView.exe  "/DataFile:PerfViewData.etl" /BufferSizeMB:256 /StackCompression /CircularMB:2048 /KernelEvents:None /Process:"dotnet" /ClrEvents:None /Providers:"*Microsoft-Build" /NoGui /NoNGenRundown /Merge:False /Zip:False run D:\msbuild\artifacts\bin\bootstrap\core\dotnet.exe build D:\OrchardCore\OrchardCore.slnx --no-restore -mt

5. Export events from PerfView

Open the .etl in PerfView → Events tab → filter Microsoft-Build → select event types (TaskHostDispatchStart/Stop, TaskExecuteInHostStart/stop) → Make sure that MaxRet parameter is set to all events → Load view → Save View As CSV.

6. Run analysis scripts

parse_overhead.py

python parse_overhead.py        # produces task_host_summary.csv

@OvesN OvesN self-assigned this Apr 16, 2026
@OvesN
OvesN force-pushed the dev/veronikao/measure-task-host-overhead-in-mt branch 3 times, most recently from 29fe5dc to e5caffe Compare April 16, 2026 14:46
OvesN and others added 2 commits April 16, 2026 16:53
…task host

Remove the attribute from all built-in tasks and intrinsic tasks to route
them through the out-of-proc task host in MT mode. This enables measuring
task host overhead by ensuring tasks go through the TaskHost dispatch path
instead of running in-process.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add four new events to MSBuildEventSource:
- TaskHostDispatchStart/Stop (107/108): wraps TaskHostTask.Execute()
  in the main process, measuring the full task host roundtrip
- TaskExecuteInHostStart/Stop (109/110): wraps task.Execute() inside
  the task host process, measuring actual task execution time

Overhead = Dispatch time - Execute time

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@OvesN
OvesN force-pushed the dev/veronikao/measure-task-host-overhead-in-mt branch from e5caffe to ce341bf Compare April 16, 2026 14:53
@OvesN

OvesN commented Apr 17, 2026

Copy link
Copy Markdown
Contributor Author

Measured https://github.com/OrchardCMS/OrchardCore build task overhead.

Command used:

D:\msbuild\artifacts\bin\bootstrap\core\dotnet.exe build-server shutdown
D:\msbuild\artifacts\bin\bootstrap\core\dotnet.exe clean
PerfView.exe  "/DataFile:PerfViewData.etl" /BufferSizeMB:256 /StackCompression /CircularMB:2048 /KernelEvents:None /Process:"dotnet" /ClrEvents:None /Providers:"*Microsoft-Build" /NoGui /NoNGenRundown /Merge:False /Zip:False run D:\msbuild\artifacts\bin\bootstrap\core\dotnet.exe build D:\OrchardCore\OrchardCore.slnx --no-restore -mt

task_host_summary.csv

overhead_dashboard

…es and remove parse_overhead.py

Keep only the ETW event additions (TaskHostDispatchStart/Stop,
TaskExecuteInHostStart/Stop) for measuring task host overhead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@OvesN
OvesN marked this pull request as ready for review April 20, 2026 08:48
Copilot AI review requested due to automatic review settings April 20, 2026 08:48
Comment thread src/Build/Instance/TaskFactories/TaskHostTask.cs
Comment thread src/MSBuild/OutOfProcTaskAppDomainWrapperBase.cs Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds new ETW instrumentation to quantify out-of-proc task host overhead in MT builds by measuring (1) main-process task-host dispatch roundtrips and (2) in-task-host task.Execute() durations.

Changes:

  • Added four new ETW events (IDs 107–110) to MSBuildEventSource to mark task-host dispatch and in-host execution start/stop.
  • Instrumented TaskHostTask.Execute() (main process) with dispatch start/stop events.
  • Instrumented OutOfProcTaskAppDomainWrapperBase (task host process) with in-host execution start/stop events.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
src/MSBuild/OutOfProcTaskAppDomainWrapperBase.cs Emits ETW start/stop around wrappedTask.Execute() inside the task host process.
src/Framework/MSBuildEventSource.cs Defines new ETW events for task-host dispatch and in-host execution timing.
src/Build/Instance/TaskFactories/TaskHostTask.cs Emits ETW start/stop around the main-process task-host roundtrip.

Comment thread src/Build/Instance/TaskFactories/TaskHostTask.cs Outdated
Comment thread src/Build/Instance/TaskFactories/TaskHostTask.cs
Comment thread src/MSBuild/OutOfProcTaskAppDomainWrapperBase.cs Outdated

@AR-May AR-May left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Comment thread src/Framework/MSBuildEventSource.cs Outdated
@OvesN
OvesN enabled auto-merge (squash) April 21, 2026 11:13
@OvesN
OvesN merged commit 97e3065 into dotnet:main Apr 23, 2026
10 checks passed
DustinCampbell pushed a commit to DustinCampbell/msbuild that referenced this pull request Apr 23, 2026
Fixes dotnet#13372

## Context

In multi-threaded (MT) mode, tasks that are not marked with
`[MSBuildMultiThreadableTask]` are routed to an out-of-proc **task
host** process for isolation. This introduces overhead from IPC
serialization, process communication, environment setup, assembly
loading, and parameter marshaling - all on top of the actual task
execution time.

This PR uses ETW instrumentation to measure that overhead and provides
tooling to analyze the results.

## Changes Made

### Added ETW events to `MSBuildEventSource` (events 107–110)

| Event ID | Name | Location | What it measures |
|----------|------|----------|------------------|
| 107 | `TaskHostDispatchStart` | `TaskHostTask.Execute()` (main
process) | Start of full task host roundtrip |
| 108 | `TaskHostDispatchStop` | `TaskHostTask.Execute()` (main process)
| End of full roundtrip (includes IPC + remote execution) |
| 109 | `TaskExecuteInHostStart` | `OutOfProcTaskAppDomainWrapperBase`
(task host process) | Start of actual `task.Execute()` |
| 110 | `TaskExecuteInHostStop` | `OutOfProcTaskAppDomainWrapperBase`
(task host process) | End of actual `task.Execute()` |

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Jan Provazník <janprovaznik@microsoft.com>
OvesN added a commit that referenced this pull request Jul 8, 2026
Fixes #14097

## Context
Out-of-proc task hosts re-send the same **build-invariant** data in
every `TaskHostConfiguration` (and echo the environment back in every
`TaskHostTaskComplete`). Two payloads dominate this redundancy on large
`-mt` builds:

1. The **build process environment** (~6 KB), shipped forward in every
config and back in every result.
2. On solution builds, the **`CurrentSolutionConfigurationContents`**
global property — an XML blob describing every project's configuration
(~57 KB on OrchardCore) — shipped forward in every config. It is ~99% of
the global-properties payload and ~48% of all task-host config bytes.

Both are invariant for the vast majority of tasks, yet re-transmitted in
full to connections that already received them.

## Changes Made

- **Send the environment as a delta.** Under a negotiated wire format
(PacketVersion 5) the environment is sent in full only once per
task-host connection; subsequent packets whose environment is unchanged
carry a 1-byte "identical" marker instead of the full dictionary, on
**both** the forward (config) and return (result) paths. The receiver
reconstructs it from the last full environment seen on that connection.

- **Deduplicate the solution configuration blob.** Solution
(`.sln`/`.slnx`) builds inject `CurrentSolutionConfigurationContents` as
a global property on every project. It is build-invariant (it describes
the *solution*, not the project), but it is re-serialized into every
`TaskHostConfiguration`. It cannot simply be dropped — a task may read
it at runtime via `IBuildEngine6.GetGlobalProperties()` — so the same
per-connection delta is applied (PacketVersion 65: the value is carried
in its **own field**, excluded from the global-properties dictionary on
the wire (so the large blob is never serialized twice), preceded by a
1-byte Full/Identical marker. It is sent once per connection, then only
a marker; the task host reconstructs it from the connection baseline.

- **Skip redundant per-task environment apply/restore** in the task host
when the next task's environment is unchanged from the previous one.

- **Bug fixes:**
- *Self-clear:* feeding the reused (unchanged) environment back into
`SetEnvironment` cleared it, because in `-mt` it was the driver's own
backing dictionary. Fixed with a reference-equality guard.

## Testing

- Round-trip serialization tests for the new Full/Identical wire forms:
environment and `CurrentSolutionConfigurationContents` at PacketVersion
5
- `-mt` end-to-end regression tests: environment observed across
consecutive task-host tasks, and an environment change made by one
task-host task is observed by the next (verified failing before the
aliasing fix, passing after).
- Validated end-to-end: full build incl. net35, and OrchardCore.slnx
`-mt` Rebuild succeeds.
- Existing task-host test suites pass.

## Measurements

**Workload:** OrchardCore (`OrchardCore.slnx`), `-t:Rebuild -mt` —
17,975 task-host task invocations. Serialized payload sizes measured
with
[ThProfile](2b19f76#diff-25ae7637236c1204a382324f95effa1822c33fcf7e805daea2e08d3faf185d02)
(write-side stream-position deltas; deterministic, single run). Baseline
= upstream `main`; patched = this PR.

| Task-host IPC payload | Baseline | This PR | Saved |
| --- | --- | --- | --- |
| Build process environment (forward + return) | 122.0 MB | 0.1 MB | **≈
122 MB** |
| `CurrentSolutionConfigurationContents` (forward) | 647.4 MB | 1.8 MB |
**≈ 646 MB** |
| **Total task-host IPC** (config + result) | **≈ 1,548 MB** | **≈ 764
MB** | **≈ 785 MB (−51%)** |

Per direction:

| Field | Baseline | This PR | Reduction |
| --- | --- | --- | --- |
| `TaskHostConfiguration` total (forward) | 1,342 MB | 620 MB | **−54%**
|
| &nbsp;&nbsp;↳ environment | 61.0 MB | 0.1 MB | −99.8% |
| &nbsp;&nbsp;↳ `CurrentSolutionConfigurationContents` | 647.4 MB | 1.8
MB | −99.7% |
| &nbsp;&nbsp;↳ global properties (incl. the blob) | 653.6 MB | 7.9 MB |
−98.8% |
| `TaskHostTaskComplete` total (return) | 206.4 MB | 143.3 MB | **−31%**
(all from the environment) |

The environment was ~4.6% of every config packet and ~29.7% of every
result packet; `CurrentSolutionConfigurationContents` was ~48% of every
config packet. After the change each carries only a 1-byte "unchanged"
marker once the connection baseline is established. The remaining config
payload is dominated by per-task task parameters, which are not
invariant and so are not deduplicated.

### Wall-clock / overhead

Sending the environment as a delta, deduplicating the solution
configuration, and skipping the redundant per-task environment
apply/restore did **not** measurably change total wall-clock time or
task-host overhead in my measurements — the IPC savings are below the
run-to-run noise floor on my machine. This is an IPC-volume optimization
(fewer bytes serialized/transmitted/deserialized), not a critical-path
one.

I measured TaskHost overhead on OrchardCore after restore on my machine
three times and took the average, using this
[guide](#13555 (comment))
to compute task host overhead. (Note: in this guide all
`MSBuildMultiThreadableTask` parameters were deliberately deleted; I
kept them, of course.)

The measurement showed no significant improvement on total time for task
and overhead:
Baseline:
<img width="2361" height="1034" alt="baseline"
src="https://github.com/user-attachments/assets/a95cfa2e-2af3-47ae-8be4-51a069a06e65"
/>
Patched version:
<img width="2365" height="1034" alt="patched"
src="https://github.com/user-attachments/assets/b9c98c88-f00d-4244-843a-a6fa6ac6e303"
/>

| Metric | Before → After | Delta | Surface reading |
| --- | --- | --- | --- |
| Total task-host time | 871,916 → 1,008,858 ms | **+136,942 ms
(+15.7%)** | looks like regression |
| Overhead share | 58.6% → 54.4% | **−4.2 pp (−7.2% rel.)** | looks like
improvement |

Both deltas are likely noise (the two metrics point in opposite
directions, and `execute_ms` — task work this change cannot affect —
moved by a similar magnitude, indicating machine-load drift rather than
a real effect).

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.

Measure the task host overhead in mt mode

5 participants