-
-
Notifications
You must be signed in to change notification settings - Fork 129
Expand file tree
/
Copy pathprofile-tunit.sh
More file actions
338 lines (288 loc) · 13 KB
/
Copy pathprofile-tunit.sh
File metadata and controls
338 lines (288 loc) · 13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
#!/usr/bin/env bash
#
# profile-tunit.sh — Build, profile, and analyze a TUnit test project.
#
# Produces:
# <output-dir>/trace.nettrace — Full execution trace (open in PerfView, VS, or speedscope)
# <output-dir>/trace.speedscope.json — Speedscope JSON (open at https://speedscope.app)
# <output-dir>/counters.csv — Runtime counters (GC, threadpool, CPU, etc.)
# <output-dir>/report-exclusive.txt — Top-N hot functions by exclusive (self) time
# <output-dir>/report-inclusive.txt — Top-N hot functions by inclusive time
# <output-dir>/dump.dmp — (optional) Full memory dump for heap analysis
#
# After collection, automatically runs `dotnet-trace report topN` to print
# the hottest functions directly in the terminal.
#
# Prerequisites:
# dotnet tool install -g dotnet-trace
# dotnet tool install -g dotnet-counters
# dotnet tool install -g dotnet-dump (optional, for --dump)
#
# Usage:
# ./scripts/profile-tunit.sh [options]
#
# Examples:
# # Profile with the default profiling project (benchmarks/TUnit.Profile)
# ./scripts/profile-tunit.sh
#
# # Profile specific tests in TUnit.TestProject
# ./scripts/profile-tunit.sh --project tests/TUnit.TestProject --filter "/*/*/BasicTests/*"
#
# # Profile with a memory dump and top 50 hot functions
# ./scripts/profile-tunit.sh --dump --top 50
#
# # Use a specific framework
# ./scripts/profile-tunit.sh --framework net9.0
set -euo pipefail
# ── Defaults ──────────────────────────────────────────────────────────────────
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PROJECT="benchmarks/TUnit.Profile"
FRAMEWORK="net10.0"
CONFIGURATION="Release"
FILTER=""
OUTPUT_DIR=""
COLLECT_DUMP=false
TRACE_PROFILE="cpu-sampling" # cpu-sampling | gc-verbose | gc-collect | none
COUNTERS_INTERVAL=1 # seconds between counter snapshots
TRACE_FORMAT="speedscope" # speedscope | chromium | nettrace
TOP_N=30 # number of hot functions to display
NO_ANALYZE=false
EXTRA_ARGS=()
# ── Usage ─────────────────────────────────────────────────────────────────────
usage() {
cat <<'USAGE'
Usage: profile-tunit.sh [options] [-- extra-test-args...]
Options:
--project <name> Test project to profile (default: TUnit.Profile)
--framework <tfm> Target framework (default: net10.0)
--configuration <cfg> Build configuration (default: Release)
--filter <treenode> Test treenode filter (e.g. "/*/*/BasicTests/*")
--output <dir> Output directory (default: .profile/<project>-<timestamp>)
--trace-profile <p> Trace profile: cpu-sampling, gc-verbose, gc-collect, none (default: cpu-sampling)
--trace-format <f> Trace export format: speedscope, chromium, nettrace (default: speedscope)
--counters-interval <s> Counter collection interval in seconds (default: 1)
--top <n> Number of hot functions to display (default: 30)
--dump Also capture a memory dump during test execution
--no-build Skip the build step (use existing build output)
--no-analyze Skip the hot-path analysis step
--help Show this help
Everything after '--' is passed directly to the test executable.
USAGE
exit 0
}
# ── Parse arguments ───────────────────────────────────────────────────────────
SKIP_BUILD=false
while [[ $# -gt 0 ]]; do
case "$1" in
--project) PROJECT="$2"; shift 2 ;;
--framework) FRAMEWORK="$2"; shift 2 ;;
--configuration) CONFIGURATION="$2"; shift 2 ;;
--filter) FILTER="$2"; shift 2 ;;
--output) OUTPUT_DIR="$2"; shift 2 ;;
--trace-profile) TRACE_PROFILE="$2"; shift 2 ;;
--trace-format) TRACE_FORMAT="$2"; shift 2 ;;
--counters-interval) COUNTERS_INTERVAL="$2"; shift 2 ;;
--top) TOP_N="$2"; shift 2 ;;
--dump) COLLECT_DUMP=true; shift ;;
--no-build) SKIP_BUILD=true; shift ;;
--no-analyze) NO_ANALYZE=true; shift ;;
--help) usage ;;
--) shift; EXTRA_ARGS=("$@"); break ;;
*) echo "Unknown option: $1"; usage ;;
esac
done
# ── Resolve paths ─────────────────────────────────────────────────────────────
PROJECT_DIR="$REPO_ROOT/$PROJECT"
if [[ ! -d "$PROJECT_DIR" ]]; then
echo "ERROR: Project directory not found: $PROJECT_DIR"
exit 1
fi
PROJECT_NAME="${PROJECT##*/}"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
if [[ -z "$OUTPUT_DIR" ]]; then
OUTPUT_DIR="$REPO_ROOT/.profile/${PROJECT_NAME}-${TIMESTAMP}"
fi
mkdir -p "$OUTPUT_DIR"
# Resolve the built executable path
if [[ "$(uname -o 2>/dev/null || true)" == "Msys" || "$(uname -s)" == MINGW* || "$(uname -s)" == CYGWIN* ]]; then
EXE_NAME="${PROJECT_NAME}.exe"
else
EXE_NAME="$PROJECT_NAME"
fi
EXE_PATH="$PROJECT_DIR/bin/$CONFIGURATION/$FRAMEWORK/$EXE_NAME"
echo "==================================================================="
echo " TUnit Profiler"
echo "==================================================================="
echo " Project: $PROJECT"
echo " Framework: $FRAMEWORK"
echo " Configuration: $CONFIGURATION"
echo " Filter: ${FILTER:-<none - all tests>}"
echo " Trace profile: $TRACE_PROFILE"
echo " Top-N: $TOP_N"
echo " Output: $OUTPUT_DIR"
echo "==================================================================="
echo ""
# ── Step 1: Build ─────────────────────────────────────────────────────────────
if [[ "$SKIP_BUILD" == false ]]; then
echo ">> Building $PROJECT ($CONFIGURATION | $FRAMEWORK)..."
dotnet build "$PROJECT_DIR" \
-c "$CONFIGURATION" \
-f "$FRAMEWORK" \
--nologo \
-v quiet \
-p:TreatWarningsAsErrors=false
echo " Build complete"
echo ""
else
echo ">> Skipping build (--no-build)"
echo ""
fi
if [[ ! -f "$EXE_PATH" ]]; then
echo "ERROR: Executable not found at: $EXE_PATH"
echo " Try building without --no-build, or check --framework and --configuration."
exit 1
fi
# ── Build test command ────────────────────────────────────────────────────────
TEST_CMD=("$EXE_PATH")
if [[ -n "$FILTER" ]]; then
TEST_CMD+=("--treenode-filter" "$FILTER")
fi
if [[ ${#EXTRA_ARGS[@]} -gt 0 ]]; then
TEST_CMD+=("${EXTRA_ARGS[@]}")
fi
# ── Step 2: dotnet-trace ──────────────────────────────────────────────────────
TRACE_FILE="$OUTPUT_DIR/trace.nettrace"
if [[ "$TRACE_PROFILE" != "none" ]]; then
echo ">> Collecting trace (profile: $TRACE_PROFILE)..."
TRACE_ARGS=(
collect
--output "$TRACE_FILE"
--profile "$TRACE_PROFILE"
--format "NetTrace" # always collect as nettrace first
--
"${TEST_CMD[@]}"
)
dotnet-trace "${TRACE_ARGS[@]}" 2>&1 | tee "$OUTPUT_DIR/trace.log"
echo " Trace saved: $TRACE_FILE"
# Convert to requested format if not nettrace
if [[ "$TRACE_FORMAT" != "nettrace" && -f "$TRACE_FILE" ]]; then
echo " Converting to $TRACE_FORMAT..."
CONVERTED_FILE="$OUTPUT_DIR/trace.$TRACE_FORMAT.json"
# dotnet-trace convert appends its own extension, so use a temp name
TEMP_CONVERT="$OUTPUT_DIR/trace-convert"
dotnet-trace convert "$TRACE_FILE" --format "$TRACE_FORMAT" --output "$TEMP_CONVERT" 2>/dev/null || true
# Find whatever file it actually created and rename
CREATED=$(find "$OUTPUT_DIR" -maxdepth 1 -name 'trace-convert*' -type f 2>/dev/null | head -1)
if [[ -n "$CREATED" ]]; then
mv "$CREATED" "$CONVERTED_FILE"
echo " Converted: $CONVERTED_FILE"
fi
fi
echo ""
else
echo ">> Skipping trace collection (--trace-profile none)"
echo ""
fi
# ── Step 3: dotnet-counters ──────────────────────────────────────────────────
COUNTERS_FILE="$OUTPUT_DIR/counters.csv"
echo ">> Collecting runtime counters (interval: ${COUNTERS_INTERVAL}s)..."
# Run the test exe in the background and attach counters
"${TEST_CMD[@]}" &
TEST_PID=$!
# Give the process a moment to start
sleep 1
if kill -0 "$TEST_PID" 2>/dev/null; then
COUNTER_PROVIDERS="System.Runtime,Microsoft.AspNetCore.Hosting,Microsoft-Extensions-DependencyInjection"
dotnet-counters collect \
--process-id "$TEST_PID" \
--output "$COUNTERS_FILE" \
--format csv \
--refresh-interval "$COUNTERS_INTERVAL" \
--counters "$COUNTER_PROVIDERS" \
2>&1 | tee "$OUTPUT_DIR/counters.log" &
COUNTERS_PID=$!
# Wait for the test process to finish
wait "$TEST_PID" 2>/dev/null || true
# Give counters a moment to flush, then stop
sleep 2
kill "$COUNTERS_PID" 2>/dev/null || true
wait "$COUNTERS_PID" 2>/dev/null || true
echo " Counters saved: $COUNTERS_FILE"
else
echo " Test process exited too quickly for counter collection"
wait "$TEST_PID" 2>/dev/null || true
fi
echo ""
# ── Step 4: Memory dump (optional) ───────────────────────────────────────────
if [[ "$COLLECT_DUMP" == true ]]; then
DUMP_FILE="$OUTPUT_DIR/dump.dmp"
echo ">> Collecting memory dump..."
# Run test exe again, capture dump mid-execution
"${TEST_CMD[@]}" &
DUMP_PID=$!
# Wait a bit for the process to get into steady state
sleep 3
if kill -0 "$DUMP_PID" 2>/dev/null; then
dotnet-dump collect \
--process-id "$DUMP_PID" \
--output "$DUMP_FILE" \
--type Full \
2>&1 | tee "$OUTPUT_DIR/dump.log"
echo " Dump saved: $DUMP_FILE"
# Let the test finish
wait "$DUMP_PID" 2>/dev/null || true
else
echo " Test process exited before dump could be captured"
echo " Try using a filter that selects more/slower tests"
fi
echo ""
fi
# ── Step 5: Analyze trace ────────────────────────────────────────────────────
if [[ "$NO_ANALYZE" == false && -f "$TRACE_FILE" ]]; then
echo "==================================================================="
echo " Hot Path Analysis (top $TOP_N by exclusive time)"
echo "==================================================================="
echo ""
dotnet-trace report "$TRACE_FILE" topN -n "$TOP_N" 2>&1 | tee "$OUTPUT_DIR/report-exclusive.txt"
echo ""
echo "==================================================================="
echo " Hot Path Analysis (top $TOP_N by inclusive time)"
echo "==================================================================="
echo ""
dotnet-trace report "$TRACE_FILE" topN --inclusive -n "$TOP_N" 2>&1 | tee "$OUTPUT_DIR/report-inclusive.txt"
echo ""
fi
# ── Summary ───────────────────────────────────────────────────────────────────
echo "==================================================================="
echo " Profiling complete! Output: $OUTPUT_DIR"
echo "==================================================================="
echo ""
echo " Files:"
for f in "$OUTPUT_DIR"/*; do
if [[ -f "$f" ]]; then
SIZE=$(du -h "$f" 2>/dev/null | cut -f1)
echo " $SIZE $(basename "$f")"
fi
done
echo ""
echo " Further analysis:"
echo ""
echo " Trace (.nettrace):"
echo " - Visual Studio: File > Open > trace.nettrace"
echo " - PerfView: perfview.exe trace.nettrace"
echo " - speedscope: https://speedscope.app (open trace.speedscope.json)"
echo ""
echo " Counters (.csv):"
echo " - Excel/LibreOffice: Open counters.csv"
echo " - Python: pandas.read_csv('counters.csv')"
echo ""
if [[ "$COLLECT_DUMP" == true ]]; then
echo " Dump (.dmp):"
echo " - Visual Studio: File > Open > dump.dmp"
echo " - dotnet-dump: dotnet-dump analyze dump.dmp"
echo " > dumpheap -stat (heap statistics)"
echo " > dumpheap -type <Type> (find specific types)"
echo " > gcroot <addr> (find GC roots)"
echo ""
fi
echo "==================================================================="