config-disassembler 0.11.9

Disassemble config files into smaller files and reassemble on demand.
Documentation
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
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
name: Profile

# Profiles the crate under sf-decomposer's real workload (see
# examples/replay_workload.rs) instead of micro benches: the CPU-only
# `xml_micro` benches got 25-36% faster in #154 while sf-decomposer's
# end-to-end CPU time didn't move, so the hot path is somewhere they don't
# measure. This job answers "where does the time / memory actually go".
#
# Fixtures come from sf-decomposer's own deterministic generator
# (scripts/gen-perf-fixtures.ts), so the shapes match its perf suite.
#
# Outputs (job summary + artifact):
#   - unprofiled wall time and max RSS per phase (/usr/bin/time -v), to check
#     the replay is representative of sf-decomposer's perf comment
#   - perf: top functions by self time, by inclusive time, and a flamegraph SVG
#
# Same-runner A/B (set `compare_ref`): builds the replay from `compare_ref` and
# from the dispatched ref on ONE runner and runs them in alternating order for
# `rounds` rounds (fresh fixture copy + dropped page cache before every run).
# Cross-runner comparisons swing by tens of percent on hosted runners; this is
# what made #158's -35% decompose result conclusive. Reports per-phase medians
# and how many rounds the dispatched ref won.
#
# Heap profile (set `heap`): builds the replay with `--features dhat-heap`, so
# every allocation goes through dhat, and reports per phase which crate call
# sites hold the most bytes at the global heap peak (examples/dhat_summary.py).
# dhat keeps per-call-site totals rather than a record per allocation, so unlike
# heaptrack (tried earlier; it got the hosted runner shut down even on `medium`)
# it handles `large`. Compare its heap peak with the baseline's max RSS: the gap
# is memory the allocator holds rather than live data.

on:
  workflow_dispatch:
    inputs:
      profile:
        description: sf-decomposer fixture profile
        type: choice
        default: large
        options: [small, medium, large, xlarge, manyfiles]
      format:
        description: Shard format passed to disassemble
        type: choice
        default: xml
        options: [xml, json, json5, yaml]
      sf_decomposer_ref:
        description: sf-decomposer ref to take the fixture generator from
        type: string
        default: main
      compare_ref:
        description: Same-runner A/B against this ref (e.g. main). Empty = profile only.
        type: string
        default: ''
      rounds:
        description: A/B rounds (only with compare_ref)
        type: string
        default: '6'
      heap:
        description: Heap profile with dhat instead (what is live at the heap peak, per phase)
        type: boolean
        default: false
  # Run on PRs that change the profiling setup itself, so it can be iterated
  # on before it exists on main (workflow_dispatch only sees main's copy).
  pull_request:
    paths:
      - examples/replay_workload.rs
      - .github/workflows/profile.yml

permissions:
  contents: read

# PR runs supersede each other; manual dispatches never cancel one another (two
# dispatches on the same ref, e.g. repeated A/B runs, must both complete).
concurrency:
  group: profile-${{ github.event.pull_request.number || github.run_id }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
  PROFILE: ${{ inputs.profile || 'large' }}
  FORMAT: ${{ inputs.format || 'xml' }}
  CARGO_TERM_COLOR: never
  # Frame pointers so perf can unwind with --call-graph fp (cheap, small
  # perf.data); line tables so symbols resolve to source lines.
  RUSTFLAGS: -C force-frame-pointers=yes
  CARGO_PROFILE_RELEASE_DEBUG: line-tables-only

jobs:
  profile:
    if: ${{ !inputs.compare_ref && !inputs.heap }}
    name: Profile (${{ inputs.profile || 'large' }}, ${{ inputs.format || 'xml' }})
    runs-on: ubuntu-latest
    timeout-minutes: 45
    steps:
      - name: Checkout
        uses: actions/checkout@v7
        with:
          persist-credentials: false

      - name: Checkout sf-decomposer (fixture generator)
        uses: actions/checkout@v7
        with:
          repository: mcarvin8/sf-decomposer
          ref: ${{ inputs.sf_decomposer_ref || 'main' }}
          path: sf-decomposer
          persist-credentials: false

      - name: Setup Node
        uses: actions/setup-node@v7
        with:
          node-version: lts/*

      - name: Generate fixtures
        # The generator only uses node built-ins, so no `npm ci` is needed.
        run: |
          npx --yes tsx scripts/gen-perf-fixtures.ts --profile "$PROFILE" --out "$RUNNER_TEMP/fixtures"
        working-directory: sf-decomposer

      - name: Install Rust toolchain
        uses: actions-rust-lang/setup-rust-toolchain@v2
        with:
          toolchain: stable
          cache: false

      - uses: Swatinem/rust-cache@v2
        with:
          cache-bin: false

      - name: Install inferno (flamegraphs)
        uses: taiki-e/install-action@v2
        with:
          tool: inferno

      - name: Install perf
        run: |
          sudo apt-get update -qq
          # Package layout differs across runner images: newer Ubuntu ships a
          # `linux-perf` package; older ones need the kernel-matched
          # linux-tools package. Try both; whichever yields a working binary wins.
          sudo apt-get install -y -qq linux-tools-common
          sudo apt-get install -y -qq linux-perf \
            || sudo apt-get install -y -qq "linux-tools-$(uname -r)" \
            || sudo apt-get install -y -qq linux-tools-azure || true
          # Prefer a versioned binary (the /usr/bin/perf wrapper on older images
          # refuses to run when the tools package doesn't match the kernel), and
          # keep the first candidate that actually executes.
          PERF=""
          for candidate in /usr/lib/linux-tools/*/perf /usr/lib/linux-tools-*/perf \
                           /usr/lib/linux-perf*/perf "$(command -v perf || true)"; do
            if [ -x "$candidate" ] && "$candidate" --version >/dev/null 2>&1; then
              PERF="$candidate"; break
            fi
          done
          if [ -z "$PERF" ]; then
            echo "::error::no working perf binary found"; find / -name perf -type f 2>/dev/null | head; exit 1
          fi
          echo "PERF=$PERF" >> "$GITHUB_ENV"
          "$PERF" --version
          sudo sysctl -w kernel.perf_event_paranoid=-1 kernel.kptr_restrict=0

      - name: Build replay
        run: cargo build --release --example replay_workload

      - name: Unprofiled baseline (wall time, max RSS)
        run: |
          BIN=target/release/examples/replay_workload
          OUT="$RUNNER_TEMP/out"; mkdir -p "$OUT"
          cp -r "$RUNNER_TEMP/fixtures" "$RUNNER_TEMP/base"
          for phase in decompose recompose; do
            /usr/bin/time -v "$BIN" "$phase" "$RUNNER_TEMP/base" --format "$FORMAT" \
              2> "$OUT/time-$phase.txt"
          done
          {
            echo "## Unprofiled baseline (profile=$PROFILE, format=$FORMAT)"
            echo
            echo "Compare with sf-decomposer's perf comment rows \`$PROFILE.$FORMAT.decompose\` / \`.recompose\`."
            echo
            echo '| Phase | Wall | User CPU (s) | Sys CPU (s) | Max RSS (MB) |'
            echo '|---|---:|---:|---:|---:|'
            for phase in decompose recompose; do
              f="$OUT/time-$phase.txt"
              wall=$(grep 'Elapsed (wall clock)' "$f" | awk '{print $NF}')
              user=$(grep 'User time' "$f" | awk '{print $NF}')
              sys=$(grep 'System time' "$f" | awk '{print $NF}')
              rss=$(grep 'Maximum resident' "$f" | awk '{printf "%.1f", $NF/1024}')
              echo "| $phase | $wall | $user | $sys | $rss |"
            done
            echo
          } > "$OUT/summary-baseline.md"
          # Job summaries aren't readable through the API; print to the log too.
          cat "$OUT/summary-baseline.md" | tee -a "$GITHUB_STEP_SUMMARY"

      - name: CPU profile (perf)
        run: |
          BIN=target/release/examples/replay_workload
          OUT="$RUNNER_TEMP/out"
          cp -r "$RUNNER_TEMP/fixtures" "$RUNNER_TEMP/cpu"
          for phase in decompose recompose; do
            "$PERF" record -F 499 -g --call-graph fp -o "$OUT/perf-$phase.data" -- \
              "$BIN" "$phase" "$RUNNER_TEMP/cpu" --format "$FORMAT" 2>/dev/null
            "$PERF" report -i "$OUT/perf-$phase.data" --stdio --no-children --sort symbol \
              --percent-limit 0.5 2>/dev/null > "$OUT/top-self-$phase.txt"
            "$PERF" report -i "$OUT/perf-$phase.data" --stdio --children --sort symbol \
              --percent-limit 2 -g none 2>/dev/null > "$OUT/top-inclusive-$phase.txt"
            "$PERF" script -i "$OUT/perf-$phase.data" 2>/dev/null \
              | inferno-collapse-perf > "$OUT/$phase.folded"
            inferno-flamegraph --title "$phase ($PROFILE, $FORMAT)" < "$OUT/$phase.folded" \
              > "$OUT/flamegraph-$phase.svg"
          done
          {
            for phase in decompose recompose; do
              echo "## $phase: top functions by self time"
              echo
              echo '```'
              grep -E '^\s+[0-9.]+%' "$OUT/top-self-$phase.txt" | head -n 30
              echo '```'
              echo
              echo "## $phase: top functions by inclusive time"
              echo
              echo '```'
              grep -E '^\s+[0-9.]+%' "$OUT/top-inclusive-$phase.txt" | head -n 30
              echo '```'
              echo
            done
          } > "$OUT/summary-cpu.md"
          cat "$OUT/summary-cpu.md" | tee -a "$GITHUB_STEP_SUMMARY"

      - name: Upload CPU profiles
        if: always()
        uses: actions/upload-artifact@v7
        with:
          name: profile-cpu-${{ env.PROFILE }}-${{ env.FORMAT }}
          path: |
            ${{ runner.temp }}/out/*.svg
            ${{ runner.temp }}/out/*.txt
            ${{ runner.temp }}/out/*.md
            ${{ runner.temp }}/out/*.folded
          retention-days: 30
          if-no-files-found: warn

  ab:
    name: Same-runner A/B (${{ inputs.compare_ref }} vs ${{ github.ref_name }}, ${{ inputs.profile || 'large' }}, ${{ inputs.format || 'xml' }})
    if: ${{ inputs.compare_ref && !inputs.heap }}
    runs-on: ubuntu-latest
    timeout-minutes: 60
    env:
      ROUNDS: ${{ inputs.rounds || '6' }}
      BASE_REF: ${{ inputs.compare_ref }}
      HEAD_REF: ${{ github.ref_name }}
    steps:
      - name: Checkout head (dispatched ref)
        uses: actions/checkout@v7
        with:
          path: head
          persist-credentials: false

      - name: Checkout base (compare_ref)
        uses: actions/checkout@v7
        with:
          ref: ${{ inputs.compare_ref }}
          path: base
          persist-credentials: false

      - name: Checkout sf-decomposer (fixture generator)
        uses: actions/checkout@v7
        with:
          repository: mcarvin8/sf-decomposer
          ref: ${{ inputs.sf_decomposer_ref || 'main' }}
          path: sf-decomposer
          persist-credentials: false

      - name: Setup Node
        uses: actions/setup-node@v7
        with:
          node-version: lts/*

      - name: Generate fixtures
        run: npx --yes tsx scripts/gen-perf-fixtures.ts --profile "$PROFILE" --out "$RUNNER_TEMP/fixtures"
        working-directory: sf-decomposer

      - name: Install Rust toolchain
        uses: actions-rust-lang/setup-rust-toolchain@v2
        with:
          toolchain: stable
          cache: false

      - name: Build both replays
        run: |
          for side in base head; do
            if [ ! -f "$side/examples/replay_workload.rs" ]; then
              echo "::error::$side has no examples/replay_workload.rs (compare_ref must include #155)"; exit 1
            fi
            (cd "$side" && cargo build --release --example replay_workload)
            cp "$side/target/release/examples/replay_workload" "$RUNNER_TEMP/replay-$side"
          done

      - name: Alternating rounds on one runner
        run: |
          OUT="$RUNNER_TEMP/ab"; mkdir -p "$OUT"
          TSV="$OUT/ab.tsv"
          printf 'round\tref\tphase\twall_s\tuser_s\tsys_s\tmaxrss_mb\n' > "$TSV"
          run_one() { # side round
            local side=$1 round=$2 work="$RUNNER_TEMP/work-$1"
            rm -rf "$work"; cp -r "$RUNNER_TEMP/fixtures" "$work"
            sync; echo 3 | sudo tee /proc/sys/vm/drop_caches >/dev/null
            for phase in decompose recompose; do
              /usr/bin/time -f '%e\t%U\t%S\t%M' -o "$RUNNER_TEMP/t" \
                "$RUNNER_TEMP/replay-$side" "$phase" "$work" --format "$FORMAT" 2>/dev/null
              IFS=$'\t' read -r e u s m < "$RUNNER_TEMP/t"
              printf '%s\t%s\t%s\t%s\t%s\t%s\t%.1f\n' "$round" "$side" "$phase" "$e" "$u" "$s" "$(echo "$m/1024" | bc -l)" >> "$TSV"
            done
          }
          # One untimed warm-up each, then alternate which side goes first.
          run_one base 0; run_one head 0
          for r in $(seq 1 "$ROUNDS"); do
            if [ $((r % 2)) -eq 1 ]; then run_one base "$r"; run_one head "$r"; else run_one head "$r"; run_one base "$r"; fi
          done
          cat "$TSV"
          echo
          python3 - "$TSV" > "$OUT/summary-ab.md" <<'PY'
          import csv, os, statistics, sys
          rows = [r for r in csv.DictReader(open(sys.argv[1]), delimiter='\t') if r['round'] != '0']
          env = os.environ
          print(f"## Same-runner A/B: `{env['BASE_REF']}` (base) vs `{env['HEAD_REF']}` (head)\n")
          print(f"profile={env['PROFILE']}, format={env['FORMAT']}, {env['ROUNDS']} alternating rounds after a warm-up; medians.\n")
          print('| phase | metric | base | head | change | head better in |')
          print('|---|---|---:|---:|---:|---:|')
          for phase in ('decompose', 'recompose'):
              for metric in ('wall_s', 'user_s', 'sys_s', 'maxrss_mb'):
                  base = {r['round']: float(r[metric]) for r in rows if r['ref'] == 'base' and r['phase'] == phase}
                  head = {r['round']: float(r[metric]) for r in rows if r['ref'] == 'head' and r['phase'] == phase}
                  b, h = statistics.median(base.values()), statistics.median(head.values())
                  wins = sum(1 for k in base if head[k] < base[k])
                  change = f'{(h / b - 1) * 100:+.1f}%' if b else 'n/a'
                  print(f'| {phase} | {metric} | {b:.2f} | {h:.2f} | {change} | {wins}/{len(base)} |')
          PY
          cat "$OUT/summary-ab.md" | tee -a "$GITHUB_STEP_SUMMARY"

      - name: Upload A/B results
        if: always()
        uses: actions/upload-artifact@v7
        with:
          name: ab-${{ env.PROFILE }}-${{ env.FORMAT }}
          path: ${{ runner.temp }}/ab/
          retention-days: 30
          if-no-files-found: warn

  heap:
    name: Heap profile (dhat, ${{ inputs.profile || 'large' }}, ${{ inputs.format || 'xml' }})
    if: ${{ inputs.heap }}
    runs-on: ubuntu-latest
    timeout-minutes: 45
    steps:
      - name: Checkout
        uses: actions/checkout@v7
        with:
          persist-credentials: false

      - name: Checkout sf-decomposer (fixture generator)
        uses: actions/checkout@v7
        with:
          repository: mcarvin8/sf-decomposer
          ref: ${{ inputs.sf_decomposer_ref || 'main' }}
          path: sf-decomposer
          persist-credentials: false

      - name: Setup Node
        uses: actions/setup-node@v7
        with:
          node-version: lts/*

      - name: Generate fixtures
        run: npx --yes tsx scripts/gen-perf-fixtures.ts --profile "$PROFILE" --out "$RUNNER_TEMP/fixtures"
        working-directory: sf-decomposer

      - name: Install Rust toolchain
        uses: actions-rust-lang/setup-rust-toolchain@v2
        with:
          toolchain: stable
          cache: false

      - uses: Swatinem/rust-cache@v2
        with:
          cache-bin: false

      - name: Build replay with dhat
        run: cargo build --release --example replay_workload --features dhat-heap

      - name: Heap profile per phase
        run: |
          BIN="$GITHUB_WORKSPACE/target/release/examples/replay_workload"
          OUT="$RUNNER_TEMP/heap"; mkdir -p "$OUT"
          cp -r "$RUNNER_TEMP/fixtures" "$RUNNER_TEMP/work"
          for phase in decompose recompose; do
            mkdir -p "$OUT/$phase"
            # dhat writes dhat-heap.json into the working directory.
            (cd "$OUT/$phase" && "$BIN" "$phase" "$RUNNER_TEMP/work" --format "$FORMAT" 2> stderr.txt)
            grep -E 'heap:|total' "$OUT/$phase/stderr.txt" || true
          done
          {
            echo "## Heap profile (dhat): profile=$PROFILE, format=$FORMAT"
            echo
            for phase in decompose recompose; do
              echo "### $phase"
              echo
              grep -E 'heap:' "$OUT/$phase/stderr.txt" | sed 's/^/    /' || true
              echo
              python3 examples/dhat_summary.py "$OUT/$phase/dhat-heap.json" 15
              echo
            done
          } > "$OUT/summary-heap.md"
          cat "$OUT/summary-heap.md" | tee -a "$GITHUB_STEP_SUMMARY"

      - name: Upload heap profiles
        if: always()
        uses: actions/upload-artifact@v7
        with:
          name: heap-${{ env.PROFILE }}-${{ env.FORMAT }}
          path: ${{ runner.temp }}/heap/
          retention-days: 30
          if-no-files-found: warn