Skip to content

diloco_features: paper-form Async + Streaming DiLoCo reproduction (small scale)#239

Open
jdinalt wants to merge 20 commits into
devfrom
feature/diloco-features-paper
Open

diloco_features: paper-form Async + Streaming DiLoCo reproduction (small scale)#239
jdinalt wants to merge 20 commits into
devfrom
feature/diloco-features-paper

Conversation

@jdinalt

@jdinalt jdinalt commented Jun 15, 2026

Copy link
Copy Markdown
Owner

Paper-form small-scale reproduction study of Async DiLoCo (arXiv:2401.09135 — Delayed-Nesterov, DyLU) and Streaming DiLoCo (arXiv:2501.18512 — fragmented overlapped sync), measured against synchronous DiLoCo on a single small Llama (34.4M) trained from scratch on Fineweb-Edu across 4 workers (4×4090). Lives in examples/tiny_experiments/diloco_features/.

The design was fixed as a pre-registration before any GPU time was spent (methodology, controls, and pass/fail gates predate the numbers). One run per arm at a single small scale — suggestive, not a benchmark.

Results (README §3.7–3.8)

1. Streaming trades per-token quality for wall-clock. All streaming arms are slightly worse per token (+0.06–0.14 eval) but finish faster (44.5–45.1 min vs 50.5); strided N=5 is closest per token and fastest — ≈ the baseline trajectory in real time.

2. DN-buffer depth is a non-monotonic lever. Sweeping N ∈ {4, 8, 16} from scratch: N=8 optimal (eval 3.107, ~half the gap to baseline closed), N=4 under-buffered (3.661), N=16 regresses (3.373). The paper's N=k=4 default is under-tuned for the from-scratch + staleness regime. (First clean post-#225 evidence on DN depth.)

3. Warm-start closes the async gap (headline). Pretrained 4×DDP to ~500M tokens, then re-ran the async arms + a warm sync baseline from that checkpoint: every warm async arm lands +0.03–0.06 of the warm sync baseline (vs +0.25–0.80 from scratch), and the DN-depth dependence collapses to within 0.002. The from-scratch regime, not async, was the obstacle — exactly why the source papers warm-start. (async_nodn diverges as the no-DN control; DyLU neutral on this homogeneous rig.)

What's in here

  • Harnessexperiment.sh (10-arm from-scratch matrix + DN sweep, WARM_MASTER warm arms, token-budget global stop), templates/configs/default.yaml (server-token-budget DiLoCo worker), templates/configs/warm_pretrain.yaml (plain 4×DDP pretrain), make_warm_master.py (assemble a warm server master).
  • Analysisharvest.py, plot_experiment.py, streaming.py, dn_sweep.py, plot_walltime.py, warm_compare.py, dylu_control.py, staleness.py (gate), grace_batches.py (validate-only), regen_tb.py.
  • CLIforgather diloco --token-budget accepts K/M/B suffixes (non-breaking units fix).
  • Paper-form README with results, inlined figures (mirrored under docs/examples/), and pre-registered hypotheses with their outcomes.

Caveats

Single seed, single small scale. The DyLU-neutral and N=16-regression results are the two most worth a second seed if firming up; the headline streaming / DN-optimum / warm-start effects are large.

🤖 Generated with Claude Code

jdinalt and others added 20 commits June 15, 2026 01:08
…t config

Restructure the project into a pre-registered, paper-form reproduction study of
async (DN/DyLU/grace, arXiv:2401.09135) and Streaming DiLoCo (arXiv:2501.18512),
measured against synchronous DiLoCo at small scale (4x4090, 34.4M Llama, 10 blocks).

Honest scope: on identical 4090s synchronous DiLoCo is fine; async/grace/DyLU solve
problems this homogeneous rig lacks. The study validates that the mechanisms function
and reproduce the papers' per-token *convergence* trends — it does not demonstrate the
*wall-clock* benefit (the async paper's Fig-2 win), which needs the two-population
(4090+3090) / WAN setup and is deferred to Future Directions.

Config (templates/configs/default.yaml):
- Override the inherited max_steps default (ns.total_steps, a ~16k self-stop) to -1
  with num_train_epochs=1, so the server's --token-budget is the SOLE stop authority
  for these open-ended runs. Overridable via the --max-steps dynamic arg (validate).
  End-to-end smoked: server relays save_and_stop at the budget; workers stop cleanly
  well before any step cap.

Run matrix (experiment.sh): 10 single-run arms, token-budget global stop (no per-worker
max_steps), arms tagged [trend] vs [mech]. Grace is cut from the matrix to a
validate-only mechanism check (coalesces near-simultaneous finishers AND proceeds when
alone) — its payoff is wall-clock tail-reduction under heterogeneity, not convergence.
Capture /status live DURING the run (per-worker staleness + the grace histogram vanish
once workers hit the budget and deregister); unwrap the orchestrator-routed
`status --json` (snapshot nested under "status").

analysis/: + perplexity; new streaming.py (strided/sequential at N in {2,5}),
grace_batches.py (mechanism check + all-k guardrail), staleness.py (gate split into
should-be-stale ~ k-1 vs the DyLU-on reducer-vs-control); read status.json
(total_tokens, derived staleness, grace); dn_sweep.py removed.

Related: #232 (CLI --token-budget units inconsistent with the webui's millions).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CLI `--token-budget` (the `diloco server` flag and the runtime `diloco
token-budget` subcommand) was raw tokens with no unit stated in help, while the
webui field is millions — ambiguous (#232). The human CLI flag and the
webui/orchestrator path share one parser (`build_diloco_server_command` emits the
same `--token-budget <raw>`), so redefining a bare number as "millions" would
break the webui/orchestrator and is the larger #232 change.

Instead, accept an optional case-insensitive K/M/B (or G) suffix with decimals
(`2.08B`, `2080M`, `500K`) while a bare number stays raw tokens — non-breaking
(the webui/orchestrator emit bare raw counts, unchanged) and unambiguous. Applied
to both `--token-budget` sites (metavar TOKENS, units in help).

- experiment.sh: budgets now `2.08B` / `1.04B` / `3M`; the start_one budget guard
  is a string check (was a numeric `-gt 0`, which a suffixed value would break).
- harness.sh: add a `token-budget` functional smoke recipe (workers submit with no
  `--max-steps`, so the config's max_steps=-1 lets the server's small 2M budget be
  the sole stop authority) and include it in `all`.

Addresses the #232 ambiguity non-breakingly; bare-number=millions parity with the
webui remains a separate, breaking option.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ference cmd + hyperparam doc

Tighten the study to the two papers' communication mechanisms vs the sync baseline.

Drop two arms that were not experiments:
- #9 `baseline_2w` (2w-vs-4w token-efficiency scaling) — DiLoCo's own batch-scaling
  is covered in the sibling diloco / pretrain/small-llm projects; doesn't belong here.
- #10 `wire_http_pk` (wire/transport lossless) — never an experiment, a one-off
  validation that the re-implemented gRPC+safetensors matches HTTP+pickle; already done.
Retire analysis/worker_scaling.py + analysis/verify_baseline.py with them. Matrix is
now 8 arms, all 4-worker.

Token budget 2.08B -> 1B total: the 2.08B was an unprincipled "520M/worker x 4"
artifact (inherited per-worker length x 4 workers). 1B matches the sibling diloco
long reference run and is ~2x Chinchilla — the model's Chinchilla-optimal is 525M
tokens (20 x its 26.2M non-embedding params). Single BUDGET (all 4w); BUDGET_2W gone.

README: restore the shared **reference command** (the base every arm runs, with
per-arm deltas only in the diloco server flags) and add a **"Hyperparameters (and
why)"** table documenting every magic number with its rationale — H=100, k=4, outer
LR 0.7/mom 0.9, inner AdamW lr 2.07e-4 (base 1.5e-4 x sqrt batch scaling), jitter
0.15, the ~2x DyLU spread, DN N=4, fragments {2,5}, the 1B budget, etc.

Remove the stale pre-#225 bug-artifact assets (curves.csv + PNGs; several referenced
the removed arms or a deleted script) — Results are pending, regenerated by the run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document the actual resolved config values in the §3.1 hyperparameter table:
the inner batch is per_device_train_batch_size=8 (set in small.yaml) x 4096-token
packed sequences = 32,768 tok/step/worker — 2x the 16,384-token LR reference, which
is what upscales the inner AdamW lr from base_lr 1.5e-4 by sqrt(2) to the effective
2.07e-4 (WSD schedule, 1606 warmup steps, min_lr 2.07e-5). Removes a stale "shared
with the sibling project" note on the batch (it is in fact what differs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two review agents audited the harness + analysis pipeline before the GPU runs;
no blockers. Applying the cheap, non-run-invalidating fixes they found:

- plot_experiment.py: stale "520M tokens" suptitle -> "1B total tokens".
- plot_experiment.py / streaming.py / dylu_control.py: x-axis "step" ->
  "local step (∝ total tokens)" (the design's primary axis; faithful at fixed batch).
- experiment.sh submit_sync: add --heartbeat-interval 5 (parity with async/dylu) so
  the heartbeat-driven token budget ticks at the same cadence on every arm — tighter
  equal-total-tokens alignment across the matrix.
- experiment.sh: fix the backwards v_budget comment; add a final wait_no_servers so
  the last arm's server is cleaned up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each arm now `rm -rf "$RESULTS/$name"` at the start of do_one (a named path,
internal to the already-running script — no glob, no manual sweep needed). This
makes re-runs idempotent (a fresh capture dir per arm) and removes any need to
manually clear runs/ with a wildcard `rm` between phases (a glob rm trips a
confirmation prompt that would stall an unattended sweep).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…obes)

JITTER and BUDGET join GRACE_S as env-overridable knobs so the staleness-gate
calibration / short probes can be driven without editing the file
(e.g. BUDGET=80M ./experiment.sh run async_dn4 for a ~5-min staleness probe).
Defaults unchanged (0.15 / 1B).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…/2, not k-1)

The live async_dn4 run exposed a round-robin conflation in the gate. For k
equal-average-speed jitter workers the sync cycle is round-robin, so at any instant
the workers occupy staleness {0,1,...,k-1} (one per cycle phase). The SNAPSHOT MEAN
of (sync_round - last_sync_server_round) is therefore (k-1)/2 = 1.5 at k=4 — the
signature of *full* decorrelation — while the async paper's "staleness ~ k-1" is the
PER-SUBMISSION staleness (how stale a gradient is when applied) = the MAX of the
cycle = 3. The old gate expected mean ~ k-1, which full decorrelation can never
reach (more jitter can't push the snapshot mean above (k-1)/2 at equal speed), so it
would have falsely FAILed the (correctly calculated) jitter 0.15 and triggered a
pointless recalibration loop.

staleness.py now gates the jitter arms (async_nodn, async_dn4) on snapshot
mean ~ (k-1)/2 and reports the per-submission max ~ k-1. The DyLU arms use a delay
spread (not jitter, not round-robin): dylu_off is the un-gated control, dylu_on
succeeds by landing at lower mean staleness. Verified on the live async_dn4 capture:
mean 1.50 = exp, max 3, PASS. README §3.3 + harvest comment updated to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After the de-risk async_dn4 run showed the runtime is affordable, bump the matrix
budget to 2B total (~4x Chinchilla; the model's Chinchilla-optimal is 525M from its
26.2M non-embedding params) to capture async's longer-term dynamics, where
DiLoCo-family behavior tends to separate. Distinct rationale from the old 2.08B
(which was an unprincipled 520M/worker x 4 artifact) — this is a deliberate
long-budget choice. ~500M tok/worker, ~150 sync rounds at H=100. Updates the
hyperparameter table + plot caption to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A baseline timing probe (80M, compile on) gave a steady-state step time of
~0.18-0.20 s/step. The DyLU speed spread is set so the max per-worker delay
(~0.18 s) approximately equals the step time, making the slowest worker ~2x the
fastest (a realistic 4090+3090 mix): DYLU_SPREAD = (0 .06 .12 .18). README updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… arm

Make each arm fail loud rather than limp if a worker dies below the launch
count, and stop the long compile/eval phase from starving the heartbeat
thread into a false eviction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Run the 10-arm from-scratch matrix to completion and fill §3.7 Results with
real numbers (was _TBD_ placeholders). Adds a DN-buffer depth sweep
(async_dn8/dn16 alongside the paper-default async_dn4) — the headline new
finding.

Findings (single seed, 4 workers, 2B tokens, H=100, from scratch):
- Streaming trades per-token quality for wall-clock; strided N=5 closest per
  token (+0.060) and fastest — nearly matches the baseline on relative time.
- DN buffer depth is a non-monotonic lever: N=8 optimum (+0.249 vs baseline),
  N=4 under-buffered (+0.802), N=16 over-buffered (+0.514). The paper's N=k=4
  default is under-tuned for the from-scratch + staleness regime.
- async-from-scratch does not reach the sync baseline even at the DN optimum;
  the warm-start sweep (§6) is the open question.
- DyLU neutral here (within seed noise) — the rig's induced spread is too mild.
- async_nodn diverges (control).

Harness/analysis:
- experiment.sh: add async_dn8 / async_dn16 arms (DN-buffer sweep).
- harvest.py: register the two new arms (table + staleness set).
- analysis/dn_sweep.py (new): DN-depth eval-loss figure (dn_sweep.png).
- analysis/plot_walltime.py (new): eval-loss vs relative wall-clock
  (walltime_comparison.png) — the fair axis for the streaming overlap trade.
- analysis/regen_tb.py: per-worker TB regen from captured logs (was untracked).
- README: §3.7 table + commentary; abstract TL;DR and §5 hypotheses updated
  from predictions to measured outcomes; run matrix 8->10 arms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The central from-scratch finding was that async DiLoCo trails the sync baseline
even at the DN-buffer optimum (§3.7 finding 3). This adds the pre-registered
(§2.6) test of whether that is the async path or the from-scratch *regime*:
pretrain the same arch with plain 4xDDP to ~500M tokens, then re-run the async
arms + a warm SYNC baseline from that checkpoint (2B further tokens each).

Result — async DiLoCo ≈ sync once warm-started:
- Every warm async arm lands +0.03..+0.06 eval of the warm sync baseline, vs
  +0.25..+0.80 from scratch. The from-scratch regime, not async, was the obstacle
  — exactly why the source papers warm-start their async runs.
- DN-buffer depth stops mattering warm: warm dn4 (+0.064) vs warm dn8 (+0.062),
  within 0.002 (vs a 0.55 gap from scratch). Small, well-aligned warm
  pseudo-gradients => staleness barely bites => the buffer is barely needed.
- DyLU still neutral warm (within noise).

Tooling (warm path, verified end-to-end — server loads the assembled master,
workers train from the pretrained loss ~3.2 not ~9):
- templates/configs/warm_pretrain.yaml: plain 4xDDP pretrain of the same arch
  (enable_diloco off, save_safetensors, --total-tokens => global budget).
- make_warm_master.py: assemble the trained checkpoint into a root-safetensors
  DiLoCo server master (copies safetensors, or converts .bin; keys match pristine).
- experiment.sh: start_one honors PRISTINE_WARM; WARM_MASTER arm set (warm
  baseline + warm async dn4/dn8 + dylu off/on), PRISTINE_WARM set per-call so the
  scratch arms stay on the pristine master.
- analysis/harvest.py: register the 5 warm arms (+ warm async in the staleness set).
- analysis/warm_compare.py -> assets/warm_compare.png: the gap-collapse figure.
- README §3.8 + abstract/§5 hypotheses/§6 updated to the warm-start result.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Inline the 7 result figures at their findings in the README (loss_comparison,
  walltime_comparison, streaming, dn_sweep, dylu_control, training_health,
  warm_compare) instead of referencing them by filename; update Appendix D file
  list to include the new analysis scripts + plots.
- Remove two stale PNGs not produced by this study (baseline_vs_h100.png from an
  earlier comparison, worker_scaling.png whose analysis script was retired).
- Mirror the 7 figures as per-file symlinks under docs/examples/.../assets/ so
  they render in the mkdocs build (matching the repo's example-mirror convention).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The streaming, async+DN, and DyLU arms are not directly comparable — they test
different axes under different conditions — so reporting them in one combined
table/plot implied a comparability they don't have. Split §3.7 Results into three
self-contained clusters, each with its own table, figures, and appropriate
reference:

- §3.7.1 Async + DN (equal-speed jitter): baseline + async-no-DN + DN sweep
  N=4/8/16, vs the sync baseline. loss_comparison/training_health refocused to
  these arms only (was a cross-axis "headline" mix).
- §3.7.2 Streaming (orthogonal — synchronous DiLoCo, fragmented comm): baseline +
  str2/seq2/str5, its own per-token + wall-clock comparison. plot_walltime.py is
  now streaming-only (async's wall-clock win is off-rig, §6).
- §3.7.3 DyLU (A/B under a ~2x speed spread): dylu_off control vs dylu_on, judged
  against the control — not the equal-speed baseline.

No data changes (same curves.csv); regrouped presentation + regenerated the two
refocused figures. Cross-refs (§3.4, §3.8) updated to the new subsections.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
We asserted a ~2x worker speed spread but never showed it. Add
analysis/worker_speeds.py, which recovers each worker's median ms/step from the
captured logs and confirms the spread is real and correctly ordered: step time
rises monotonically with the injected DILOCO_DEBUG_STEP_DELAY (0/.06/.12/.18s),
w0 fastest (156 ms/step, 17.7k steps) -> w3 slowest (250 ms/step, 11.9k steps),
consistent across both dylu_off and dylu_on (DyLU changes sync cadence, not
compute speed).

Correction: the realized spread is ~1.6x, not the ~2x the delays were calibrated
to target. The per-step CPU sleep partially overlaps asynchronous GPU compute, so
a 0.18s nominal delay adds only ~0.09s of wall-clock. Updated every ~2x claim to
distinguish the calibration target from the measured ~1.6x (§3.7.3, §3.3, the
hyperparameter table, run matrix, captions, experiment.sh). This further weakens
the heterogeneity DyLU had to work with, reinforcing the DyLU-neutral caveat.

Adds the worker_speeds table + figure to §3.7.3, the analysis script + docs mirror.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per review: the warm-start runs read better alongside their from-scratch
counterparts for joint analysis than as a separate §3.8 coda. Dissolve §3.8:

- Warm async (baseline + DN N=4/8) now lives at the end of §3.7.1, with a JOINT
  scratch-vs-warm table (per arm, gap to each group's own baseline) — the
  comparison the warm experiment is for.
- Warm DyLU A/B (off vs on) now lives at the end of §3.7.3.

Also: never overlay the two groups on one loss axis (their y-ranges differ vastly,
~9->2.85 scratch vs ~3.2->2.83 warm, which squashes the warm detail). warm_compare.py's
left panel is now WARM-ONLY trajectories on their own scale; the cross-comparison is
the right panel's gap bars (deltas to each group's baseline, so comparable).

Updated all §3.8 cross-refs -> §3.7.1, and the stale Related-Work arm numbers
(DN arms 5-8, DyLU arms 9-10).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…7.3)

The ~1.6x spread was too mild to exercise DyLU (no signal). Re-run with a real
4:1 spread, and warm-only (the scratch-vs-warm story is settled by the async
arms; DyLU is no exception). Smoke-verified the calibration before the long runs:
DYLU_SPREAD=(0 0.24 0.40 0.56) lands a clean 4.00x slowest/fastest step time (the
per-step CPU sleep partially overlaps async GPU compute, landing ~delay-0.09s).

New result — the mechanism fires, but the convergence payoff is unmeasurable here:
- DyLU cuts mean staleness 2.33 -> 1.50 and ~doubles sync rounds (1220 vs 615) —
  the adaptive sync_every responding to the spread as designed (the staleness gate
  now PASSES, vs the inverted noise at 1.6x).
- But eval is unmoved: warm_dylu_on 2.851 vs warm_dylu_off 2.855 (Δ 0.004, within
  noise); both reach the warm baseline. The DN buffer already absorbs the
  staleness, so reducing it further doesn't move per-token loss. DyLU's real
  payoff is wall-clock tail reduction (off-rig).

Harness/analysis:
- experiment.sh: DYLU_SPREAD -> 4:1; drop the from-scratch dylu_off/dylu_on arms
  (DyLU warm-only); smoke-verify note.
- harvest.py: drop scratch dylu; worker_speeds/dylu_control/staleness point at the
  warm_dylu_* arms; warm_compare drops the (now warm-only) DyLU rows from the gap
  chart. Regenerated dylu_control/worker_speeds/warm_compare.
- README: §3.7.3 rewritten (warm-only, verified 4:1, mechanism-fires-but-neutral);
  matrix is 8 from-scratch arms + a warm set; abstract/§3.1/§3.3/§3.4/§3.5/§4/§5/§6
  and the reproduce commands updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per review: comparing raw eval loss across the scratch (2B from random init) and
warm (2B on top of 500M) groups is meaningless. Use relative perplexity vs each
group's OWN sync baseline — "% worse perplexity than the matched baseline" — which
normalizes out the different starting points (and perplexity is the DiLoCo paper's
reporting unit).

- §3.7.1 warm table: drop the raw scratch-vs-warm loss juxtaposition; show warm
  eval/ppl + % worse ppl vs warm base, alongside the scratch arms' % worse ppl vs
  scratch base (+123%/+28% scratch -> +6.6%/+6.4% warm).
- warm_compare.py bar chart: relative perplexity (% worse vs matched baseline),
  not raw eval-loss gap; with value labels.
- Perplexity column now in every results table (added to DyLU); all comparison
  columns expressed as % worse ppl for consistency (diverged no-DN reads
  "diverged"). Prose/abstract/§5 updated to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two axis corrections to the loss-vs-x plots.

X-axis: was worker0's local step count, which is NOT comparable across arms —
worker0 is a 1/k-share worker on the equal-speed arms but the FASTEST worker on
the DyLU speed-spread arms, so the same global progress logs ~2x the steps (the
DyLU baseline appeared to "end early"). harvest.py now maps worker0 step ->
AGGREGATE tokens across all workers (step * aggregate_total / worker0_final_step,
exact since speeds are constant); curves.csv x column is now `mtokens`. Every arm
spans the same 0..2B axis.

Y-axis: perplexity (= exp(eval loss), the DiLoCo papers' axis) everywhere, with
scale chosen by dynamic range — log for the from-scratch trajectories (early ppl
~1000s would drown the converged tail), linear for the warm trajectories and the
tight endgame-zoom panels (plain ticks, best tail contrast). Grad-norm panel
unchanged (log).

- harvest.py: emit aggregate mtokens as the x; rename CSV column step -> mtokens.
- plot_experiment / dn_sweep / streaming / dylu_control / warm_compare /
  plot_walltime: read mtokens; y = perplexity; log full + linear endgame/warm;
  x label "total tokens (M, all workers)"; final-perplexity bar in streaming.
- README §3.2 + figure captions updated (perplexity, log/linear rationale).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.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.

1 participant