Skip to content

[Bug]: OpenMP spin-wait tuning is inert on macOS — and applying it costs 2.2x decode on a 32 GB host #707

Description

@fredchu

Summary

The OpenMP hot-thread tuning block in main() never reaches the OpenMP runtime on macOS, because the self-re-exec that makes it effective is compiled only for Linux and FreeBSD. On this host that turns out to be load-bearing in the good direction: applying the same settings externally makes decode 2.2x slower. So the report is less "please fix the missing branch" and more "please don't fix it without gating it, and here is the measurement".

Two separate items, both about how the OpenMP team is configured on macOS.

Commit or version

2d623816eecbf740b1fc06eb422974bda0af8931 (main, 2026-07-28)

Environment

macOS 26.5.2 (Darwin 25.5.0), Apple M1 Max (8 performance + 2 efficiency cores), 32 GB unified memory, internal Apple NVMe / APFS, Apple clang 17.0.0, Homebrew libomp, make colibri METAL=1. Model: mastouri/GLM-5.2-colibri-int4-g64-with-int8-mtp (429.3 GB, int8 MTP head). Full benchmark context in the companion performance report.

Reproduction steps

cd colibri/c && make colibri METAL=1

P="Explain why a Mixture-of-Experts model needs less RAM than its parameter count."

# A — as shipped
RAM_GB=24 COLI_METAL=1 ./coli run --model <model> --temp 0 --ngen 32 "$P"

# B — the same four variables main() tries to set, exported before launch
RAM_GB=24 COLI_METAL=1 \
OMP_WAIT_POLICY=active KMP_BLOCKTIME=200 OMP_DYNAMIC=FALSE GOMP_SPINCOUNT=200000 \
  ./coli run --model <model> --temp 0 --ngen 32 "$P"

# then one variable at a time

Expected behavior

Either the tuning applies on macOS the way it does on Linux, or it is explicitly not applied because it was measured not to help there.

Actual behavior and logs

=== Item 1: the tuning is inert on macOS, and that is currently a good thing ===

decode 32 tokens, prompt 24 tokens, greedy, RAM_GB=24, COLI_METAL=1
expert hit rate held at 9.9-10.1% across every run below, so cache state is not the variable

  A  as shipped                          218.55s / 219.39s   (mean 218.97s)
  B  four tuning vars exported           481.20s / 490.72s   (mean 485.96s)   +122%
  C  OMP_NUM_THREADS=8                   209.57s / 210.01s   (mean 209.79s)   -4.2%
  D  OMP_NUM_THREADS=8 + tuning vars     233.00s / 234.51s   (mean 233.76s)   +6.8%

A != B is itself the evidence that the in-process setenv() is not reaching the
runtime: if it were, exporting the same values would be a no-op.

Single-variable isolation (n=1 each, same conditions):

  baseline (nothing set)                 218.97s      --
  OMP_WAIT_POLICY=active                 485.76s     +122%
  KMP_BLOCKTIME=200                      471.40s     +115%
  OMP_DYNAMIC=FALSE                      220.78s      +0.8%  (noise)
  GOMP_SPINCOUNT=200000                  217.39s      -0.7%  (noise; libomp
                                                      ignores GOMP_*, as expected)

Either spin-wait knob alone reproduces the full regression. The two non-spin
knobs are inert. Same-config repeats differ by <=2%, so these are real.

Note the interaction with team size: the tuning costs +122% at 10 threads but
only +6.8% at 8 threads (B vs D). Leaving 2 cores unclaimed by the OMP team
turns a catastrophe into a minor loss.

=== Item 2: OpenMP team size includes the efficiency cores ===

  [plan] warning: lscpu core probe failed: [Errno 2] No such file or directory: 'lscpu'
  [plan] warning: physical-core probes unavailable; using 10 logical CPUs
                  (SMT may over-subscribe). Set OMP_NUM_THREADS to physical
                  cores if slow.

  OMP_NUM_THREADS unset (10 threads)     218.97s
  OMP_NUM_THREADS=8                      209.79s     -4.2%

Item 1 — why it is inert, and why not to "fix" it naively

c/colibri.c seeds the tuning with setenv(..., 0) and then, per its own comment, relies on a self-re-exec because libgomp reads the environment in its constructor, before main():

if(!getenv("COLI_OMP_TUNED") && !getenv("COLI_NO_OMP_TUNE") &&
   !getenv("COLI_CUDA") && !getenv("COLI_METAL")){
    setenv("OMP_WAIT_POLICY","active",0);
    setenv("GOMP_SPINCOUNT","200000",0);
    setenv("KMP_BLOCKTIME","200",0);
    setenv("OMP_PROC_BIND","close",0);
    setenv("OMP_DYNAMIC","FALSE",0);
    setenv("COLI_OMP_TUNED","1",1);
#ifdef __linux__
    ... execv("/proc/self/exe", argv);
#endif
#ifdef __FreeBSD__
    ... execv("/proc/curproc/file", argv);
#endif
}

There is no Darwin branch, so on macOS the setenv calls land and nothing re-reads them. I initially assumed LLVM libomp's lazy initialization (first parallel region, after main()) might pick them up anyway, which would have made this a non-issue — the A vs B measurement above rules that out.

The reason this matters: adding _NSGetExecutablePath() + execv to close the platform gap looks like an obvious cleanup, and on this host it would cost 2.2x decode throughput. The mechanism is plausible enough to expect on any low-residency host: at ~10% residency the engine leans on the bounded async I/O pool (PIPE_WORKERS, default 8) to hide ~86% of disk service time, and a spin-waiting OMP team sized to every core starves those I/O threads. The KMP_BLOCKTIME=200 mitigation added for #341 is not short enough here — with one small parallel region per expert, 200 ms of blocktime means the team effectively never sleeps.

Suggestions, in order of how confident I am:

  1. If a Darwin re-exec is ever added, exclude both spin-wait settings there, or gate the whole block on measured expert residency — the settings are tuned for hosts where experts are resident and compute dominates, which is the opposite of this regime.
  2. Consider documenting OMP_WAIT_POLICY / KMP_BLOCKTIME in docs/tuning.md as knobs that can be strongly negative on disk-streaming hosts. Right now a reader who finds them in the source is likely to try exporting them.
  3. Separate papercut in the same guard: it tests getenv("COLI_CUDA") / getenv("COLI_METAL") for presence, not value, so COLI_CUDA=0 and COLI_METAL=0 disable the tuning too. That has a reachable consequence beyond users hand-setting them — coli itself sets e["COLI_CUDA"]="0" at c/coli:239 for --auto-tier --gpu none, so on a CPU-only Linux host that documented invocation appears to skip the tuning that a bare coli run would get. I read that from the source and did not measure it (no Linux host here). atoi(getenv(...)) would cover both cases.

Item 2 — sizing the team from performance cores on Apple Silicon

physical_cpu_count() in c/resource_plan.py has a Windows branch and an lscpu branch; macOS falls through both to os.cpu_count() = 10, and warns about SMT. Apple Silicon has no SMT, so the warning's framing does not apply — but the number is still wrong for this purpose, because 2 of those 10 cores are efficiency cores. With OMP_DYNAMIC=FALSE and a barrier per expert matmul, the slowest thread paces the team.

Worth noting that a "physical cores" probe would not help here: hw.physicalcpu is also 10 on this chip. The value you want is the performance-core count:

sysctl -n hw.perflevel0.logicalcpu   # 8 on M1 Max
sysctl -n hw.perflevel1.logicalcpu   # 2 (efficiency)
sysctl -n hw.physicalcpu             # 10  <-- not the number you want

hw.perflevel* exists on all Apple Silicon; on Intel Macs it is absent, so falling back to hw.physicalcpu there is correct. Measured gain from getting this right: -4.2% decode, -6% prefill on this host, for free.

Happy to test patches for either item on this hardware.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugDifetto verificato nel codicehelp wantedExtra attention is neededmetalBackend Metal/AppleperformanceVelocità / tok-s / ottimizzazioni

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions