Compare commits

..
Author SHA1 Message Date
dmcc73andClaude Opus 4.7 31075385fc qwen3_5_moe/common: read MoE dims from weight.shape (TP correctness)
shard_inplace updates the parameter dict (weights/scales/biases) but
does NOT touch module-level dim attributes like output_dims and
input_dims. Under TP those attrs hold the original (full) values, not
the per-rank sharded values, while the actual weight tensors are
correctly halved.

Concretely for Qwen3.5-35B-A3B at TP=2:
  routed down_proj (sharded-to-all on input dim):
    true per-rank N_IN  = 512,  stale dp.input_dims = 1024
    true per-rank K_OUT = 2048, dp.output_dims     = 2048 (unchanged)
  routed gate_proj (all-to-sharded on output dim):
    true per-rank N_INTER  = 512,  stale gate_proj.output_dims = 1024
    true K_HIDDEN          = 2048, gate_proj.input_dims        = 2048

The merged_down_proj kernel uses N_IN to compute strides into the weight
buffer. With stale N_IN=1024 vs actual packed cols=N_IN_true/4=128, the
kernel addresses past the end of the sharded weight → reads garbage.
Symptom: model loads, doesn't crash, but produces gibberish/loops at
generation. Single-mini path was correct because the attrs match the
weight shape there.

Fix: read N_INTER, K_HIDDEN, K_OUT, N_IN from weight.shape and bits
(unpack via pack_factor = 32 // bits). Module attributes are never
trusted under TP. Single-mini path is unchanged (weight.shape and
output/input_dims agree there).

Also remove the alias's separate stale-attr read in _patch_swiglu_weights
— compute N_INTER once from weight.shape and reuse for both stacking
and slicing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:04:48 +01:00
dmcc73andClaude Opus 4.7 37fa4079f8 qwen3_5_moe/apply: unwrap ShardedMoE before isinstance check
Under TP, auto_parallel wraps layer.mlp with ShardedMoE BEFORE
maybe_apply_patches runs (we now call patches in shard_and_load after
tensor_auto_parallel returns). The isinstance(moe, Qwen3NextSparseMoeBlock)
guard then silently failed for every layer because layer.mlp is the
ShardedMoE wrapper, not the inner block. Result: 0/40 layers got their
weight prep done, but the class-level __call__ replacement still ran,
so attention layers crashed on first decode with:
  AttributeError: 'GatedDeltaNet' object has no attribute '_merged_proj_w'

Confirmed by the log line "Qwen3.5 batched fused: 0 GDN + 0 GQA layers,
40 total in 0.0s" — n_gdn and n_gqa never incremented.

Fix: unwrap with getattr(layer.mlp, "original_layer", layer.mlp). Falls
through cleanly on single-mini (layer.mlp is the inner block, no attr)
and pulls out the inner block under TP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:54:18 +01:00
dmcc73andClaude Opus 4.7 95e71f8c97 qwen3_5_moe: parametrize fused_qk_rmsnorm + fused_rms_norm_gated for TP
Both kernels had hardcoded shape constants (8192, 4096, 2048, HK=16,
HV=32, DV=128) baked into both their Metal source and Python wrapper
(reshape calls, output_shapes, grid). Under TP=N these constants are
wrong by a factor of N — would silently produce garbage output or
crash the dispatch.

Lift to runtime args:
  fused_qk_rmsnorm:    key_dim, value_dim, num_k_heads, head_k_dim
  fused_rms_norm_gated: num_v_heads, head_v_dim

The Metal source bakes them in at compile time (a different specialized
kernel per shape tuple, cached). N_READS = head_dim // 32 derives the
per-thread load count automatically.

_fused_gdn_call now passes self.key_dim / self.value_dim /
self.num_k_heads / self.num_v_heads / self.head_k_dim / self.head_v_dim
to both kernels. QwenShardingStrategy already updates these attributes
to per-rank values during TP sharding (see auto_parallel.py:1066-1078),
so the same code path produces the right shapes at TP=1 and TP=N.

Single-mini path is unchanged because defaults match the original
hardcoded values (key_dim=2048, value_dim=4096, num_k_heads=16,
head_k_dim=128, num_v_heads=32, head_v_dim=128).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:51:39 +01:00
dmcc73andClaude Opus 4.7 71740744d0 qwen3_5_moe: preserve residual fusion under TP via 1/N scaling
Restores _fused_decoder_call (residual fused into batched_moe_epilogue)
under TP by:

1. ShardedMoE.__call__ now forwards *args/**kwargs to the wrapped layer,
   so the _residual=h kwarg from _fused_decoder_call reaches our patched
   MoE instead of being swallowed by the wrapper.

2. ShardedMoE stashes its sharding_group on the inner MoE before each
   call. Our _batched_swiglu_down_moe_call_with_epilogue reads N =
   sharding_group.size() (1 if absent) and divides the residual H by N
   before passing it to batched_moe_epilogue.

The math: each rank's epilogue produces partial_routed_r + partial_shared_r
+ h/N. ShardedMoE then all_sums across ranks → full_routed + full_shared
+ N*(h/N) = full + h. Correct, with the residual fusion preserved.

No kernel changes — H is the same shape, just a scaled value. Single-mini
path is unaffected (sharding_group is None → N=1 → no scaling).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:47:51 +01:00
dmcc73andClaude Opus 4.7 e6e4957965 shard_and_load: apply kernel fusion patches on the post-TP model
The single-device load path (utils_mlx.py:196) calls maybe_apply_patches
right after load_model, but shard_and_load (used in distributed/TP mode)
was never calling it — so EXO_FUSED_KERNELS=1 silently did nothing under
TP=2 even though the env var was set. We confirmed this on a 2-mac-mini
TP=2 run that hit ~78 TPS with no patching log lines printed.

Insert maybe_apply_patches after tensor_auto_parallel returns. By that
point QwenShardingStrategy has already updated num_attention_heads,
key_dim, value_dim, and conv_dim to per-rank values, so the patches
operate on correctly-sized sharded weights. The patches stack and alias
weights in place and don't disturb sharding boundaries (out_proj/o_proj
remain nn.Linear, MoE __call__ still returns (B,S,K), etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:33:01 +01:00
dmcc73andClaude Opus 4.7 560ea58924 qwen3_5_moe: switch default mode to fused_attn_batched_moe (TP-friendly)
Replaces the legacy batched_fused_oproj orchestrator with the new
TP-friendly mode that keeps out_proj/o_proj inside attention as plain
nn.Linear calls (so QwenShardingStrategy + sharded_to_all_linear handle
TP all_sum unchanged) and reduces the MoE region to 5 dispatches via
the new heterogeneous-grid shared-SwiGLU || routing kernel.

Per-layer dispatch chain:
  Attention (GDN): _fused_gdn_with_outproj_call
  Attention (GQA): _batched_fused_gqa_with_oproj_call
  MoE: vanilla gate matmul -> shared_swiglu_with_routing_8bit ->
       batched_merged_routed_swiglu_with_seg_8bit ->
       batched_merged_down_proj_8bit -> batched_moe_epilogue

Measured 64.9 TPS at B=1, 4096-prompt on mac-mini-6 in mlx_bench.

Files:
  apply.py: rewrite orchestrator (no _patch_oproj_gate_rms; install new wrappers)
  common.py: add _patch_seg_weights helper
  batched_moe.py: add _batched_swiglu_down_moe_call_with_epilogue (5-dispatch)
  fused_gdn_attention.py: add _fused_gdn_with_outproj_call wrapper
  batched_fused_gqa_attention.py: add _batched_fused_gqa_with_oproj_call wrapper
  kernels/shared_swiglu_with_routing_8bit.py: NEW heterogeneous-grid kernel
  kernels/batched_merged_swiglu_8bit.py: NEW (with routed_only + with_seg flags)

The legacy oproj path code remains in the module for reference but is no
longer installed; both attention wrappers detect and skip the duplicate
o_proj on the vanilla fallback path so the legacy code stays usable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:21:13 +01:00
ciaranborandClaude Opus 4.7 8f95687d7d qwen3_5_moe: clear_cache per layer during patch loop
After each layer is patched, the freed original gate_proj/up_proj weights
sit in MLX's allocator pool rather than being returned to the OS. Across
40 layers this accumulates ~21 GB of cached-but-unused buffers, even
though MLX's own peak counter only sees ~36 GB of active memory. The
process's RSS grows to active + cache (~57 GB) which can OOM smaller
machines and shows up as a transient spike on memory dashboards.

mx.clear_cache() inside the loop forces the allocator to return the
freed buffers each layer. Process RSS during patching now stays near
the active steady state (~36 GB).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:13:29 +01:00
ciaranborandClaude Opus 4.7 d86c4220ac qwen3_5_moe: alias gate_proj/up_proj weights to slices of stacked tensor
After concatenating routed gate+up weights into _fused_w_gu (and shared
gate+up into _shared_w_gu), reassign the per-projection .weight/.scales/
.biases attributes to slices of the stacked tensor instead of leaving
them as independent allocations.

This lets the original (E, N_INTER, K/4) per-projection tensors be
garbage-collected while keeping prefill (which still calls
self.switch_mlp(...) -> self.up_proj(...)) functional, since the
projection objects now read from the shared backing memory.

Removes ~22 GB of duplicated weight storage on Qwen3.5-35B-A3B-8bit
(63 GB -> 41 GB peak in mlx_bench batched_fused_oproj_v2 bench;
gen TPS unchanged at 125, prompt TPS +16% from better cache locality).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 16:43:58 +01:00
dmcc73 f06c6de729 Debug: count standard path calls 2026-04-02 01:22:37 +01:00
dmcc73 6059bd0ae4 Debug: use print instead of logger 2026-04-02 01:19:27 +01:00
dmcc73 3dffb0787d Debug: add MTP path logging to _next() 2026-04-02 01:12:53 +01:00
dmcc73 0b45ac7123 Fix patches/__init__.py: restore apply_mlx_patches from rebased commit 2026-04-02 00:57:24 +01:00
dmcc73andClaude Opus 4.6 37ad1fb3ed Call warmup_speculative at startup to pre-compile LpB kernels
The warmup_speculative() function was defined but never called.
Custom Metal kernels (LpB) require first-call compilation (~200ms).
Without warmup, the first speculative cycle is slow, dragging down
average TPS by 10-20% on short generations.

In mlx_bench testing: cold 48 TPS → warm 60 TPS for DFlash,
cold 39 TPS → warm 44 TPS for MTP.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:15:05 +01:00
dmcc73andClaude Opus 4.6 b47a287f3e Add EXO_DISABLE_LOGPROBS=1 to skip per-token logprobs extraction
For profiling: extract_top_logprobs() does 11 .item() calls +
argpartition on 248K vocab per token. Testing if this is the
source of speculative overhead vs mlx_bench.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 23:41:47 +01:00
dmcc73andClaude Opus 4.6 e1cf376e45 Add speculative warmup: compile MTP + verify kernels at startup
The standard warmup only runs S=1 generation, leaving speculative
kernels (S>1 verify, speculative GDN kernel, MTP draft) uncompiled.
First real speculative cycle had compilation overhead.

New warmup_speculative(): prefills a short prompt, runs 3 speculative
cycles to compile all kernels before real requests arrive.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:39:34 +01:00
dmcc73andClaude Opus 4.6 75932cbcca Fix stop token dropping valid tokens before it
When <|im_end|> appeared in accepted drafts, all preceding tokens in
the cycle were returned with finish_reason="stop", causing exo to
drop them (exo skips adding tokens with finish_reason="stop").

Symptom: γ=0 outputs "20", γ=1 outputs "2", γ=2 outputs nothing —
losing exactly γ tokens at the end.

Fix: yield tokens before the stop normally (no finish_reason), buffer
the stop token, let _yield_buffered return it with finish_reason="stop".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:44:01 +01:00
dmcc73andClaude Opus 4.6 199a4ab7e0 EXO_SPECULATIVE_TEMP overrides model sampling temperature globally
When set, overrides the request's temperature for both the model's
sampler AND the speculative acceptance. This allows testing greedy
baseline (γ=0) and greedy speculative (γ=2) with the same T=0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:24:25 +01:00
dmcc73andClaude Opus 4.6 4818b9a3db EXO_SPECULATIVE_TEMP overrides request temp when set
If EXO_SPECULATIVE_TEMP is explicitly set, use it (for testing greedy).
If not set, use the request's temperature (production behavior).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:23:05 +01:00
dmcc73andClaude Opus 4.6 f0433505a8 Fix speculative temp: use request temperature, not global env var
The speculative cycle was using EXO_SPECULATIVE_TEMP (global) instead
of the request's actual temperature. This caused greedy decoding in
speculative while the model sampled at T=0.7, producing different
(shorter) output and missing responses after </think>.

Now passes task_params.temperature from submit() to MTPBatchGenerator
per-request via _request_temp[uid]. Falls back to self.temp (env var)
if not set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:21:57 +01:00
dmcc73andClaude Opus 4.6 88bc1656a2 Fix MTP prefill to use all captured positions
Was using prompt_pre_norm[:, :-1, :] (missing last position).
Now uses full prompt_pre_norm paired with all_prompt_tokens[1:S_pre+1],
matching the mlx_bench MTPBatchGenerator's prefill behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:06:53 +01:00
dmcc73andClaude Opus 4.6 7bd1ba6605 Fix MTP prefill: do it in submit() with correct prompt tokens
Bug: _CapturingEmbed was overwritten by BatchGenerator's 2-token insert,
causing MTP prefill to silently skip (len check failed: 2 < N-1). MTP
drafted without any prompt context → low acceptance → low TPS.

Fix: Do MTP prefill in ExoBatchGenerator.submit() right after main model
prefill, using all_prompt_tokens (available as local variable). Remove
_CapturingEmbed entirely. Simplify _first_step_and_prefill to just
capture decode pre_norm.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:42:20 +01:00
dmcc73andClaude Opus 4.6 e7c5d56e83 Add LpB kernel patches for Qwen3.5 dense models (27B, 9B)
Loop-over-B custom GEMV kernels for expanding projections (N > K):
gate_proj, up_proj, down_proj, in_proj_qkv, in_proj_z, out_proj, q_proj.

These reduce S>1 verification cost from ~7ms/token to ~3ms/token,
critical for speculative decoding speedup.

Auto-detected for model_type=qwen3_5 (dense models like 27B, 9B).
MoE models (qwen3_5_moe) use the existing batched fused patches instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:00:02 +01:00
dmcc73andClaude Opus 4.6 dd71182457 Fix MTP prefill for exo: capture prompt tokens via embed_tokens wrapper
Exo does its own prefill outside BatchGenerator, so batch.tokens only
has the last 2 tokens. Added _CapturingEmbed wrapper on embed_tokens to
capture the full prompt token ids during prefill. MTP prefill now uses
these captured tokens instead of batch.tokens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:57:25 +01:00
dmcc73andClaude Opus 4.6 09012d3799 Auto-extract MTP weights from HuggingFace model repo
When EXO_SPECULATIVE=1, MTP weights are resolved in order:
1. EXO_MTP_WEIGHTS=/path/to/file (explicit path)
2. EXO_MTP_MODEL=Qwen/Qwen3.5-27B (explicit HF repo)
3. Auto-detect: if model has mtp_num_hidden_layers > 0 and is
   Qwen3.5, defaults to Qwen/Qwen3.5-27B

Downloads safetensors from HF, extracts model.mtp.* tensors,
caches to ~/.cache/exo/mtp_weights/ for future use.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:19:48 +01:00
dmcc73andClaude Opus 4.6 ce19267d2d Pass temperature and alpha to MTP speculative decoding
Default temp=0.7 (matching exo's default) so probabilistic acceptance
runs correctly. Configurable via EXO_SPECULATIVE_TEMP and
EXO_SPECULATIVE_ALPHA env vars.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:12:59 +01:00
dmcc73andClaude Opus 4.6 8a65a51569 Add MTP speculative decoding for Qwen3.5 models
Integrates MTP-based speculative decoding into exo's BatchGenerator.
When enabled via EXO_SPECULATIVE=1 and EXO_MTP_WEIGHTS=<path>,
MTPBatchGenerator replaces the standard MlxBatchGenerator for BS=1
inference, drafting γ tokens with the model's built-in MTP head and
verifying at S=γ+1.

New files in speculative/:
- mtp_module.py: MTPPredictor + speculative_forward (kernel swap for
  GDN rollback) + draft_tokens (lazy MTP chaining)
- mtp_batch_generator.py: MTPBatchGenerator subclassing mlx_lm's
  BatchGenerator with token buffering and BS>1 fallback
- speculative_cache.py: SpeculativeArraysCache for GDN state rollback
- speculative_gdn_kernel.py: Metal kernel with per-step state output

Environment variables:
  EXO_SPECULATIVE=1              Enable speculative decoding
  EXO_MTP_WEIGHTS=/path/to/file  Path to MTP weights safetensors
  EXO_SPECULATIVE_GAMMA=2        Draft tokens per cycle (default: 2)

MTP weights must be extracted from the original HF model (e.g.
Qwen/Qwen3.5-27B) as they are stripped during MLX quantization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:10:11 +01:00
dmcc73andClaude Opus 4.6 a2de281c67 Replace GDN projections with register-sharing batched kernel
Old kernel used grid z=B, loading weights B times independently.
New kernel loads weights once into registers and computes B dot products.
11-14% faster at B=2-4 in full model benchmarks (194 vs 174 TPS at B=2).
B=1 generates identical code, no regression.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 15:13:39 +00:00
dmcc73andClaude Opus 4.6 9394d04f5f Add LCB TPS benchmark script
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 17:37:36 +00:00
dmcc73andClaude Opus 4.6 92c04b0aa5 Add batched fused Metal kernel patches for Qwen3.5 MoE decode
Custom Metal kernels with register-level weight sharing for batch sizes 1-8.
Fuses o_proj + RMSNorm + gate GEMV + softmax + topk + SwiGLU + down_proj + epilogue
into 4 dispatches per MoE layer, plus fused GDN and GQA attention projections.
Falls back to vanilla for B>8 or S>1 (prefill). Controlled by EXO_FUSED_KERNELS env var.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 17:36:28 +00:00
729 changed files with 17399 additions and 36255 deletions

No files matched your search

+4 -124
View File
@@ -32,6 +32,7 @@ jobs:
SPARKLE_ED25519_PRIVATE: ${{ secrets.SPARKLE_ED25519_PRIVATE }}
SPARKLE_S3_BUCKET: ${{ secrets.SPARKLE_S3_BUCKET }}
SPARKLE_S3_PREFIX: ${{ secrets.SPARKLE_S3_PREFIX }}
EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT: ${{ secrets.EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT }}
AWS_REGION: ${{ secrets.AWS_REGION }}
EXO_BUILD_NUMBER: ${{ github.run_number }}
EXO_LIBP2P_NAMESPACE: ${{ github.ref_name }}
@@ -158,7 +159,7 @@ jobs:
fi
- name: Install Homebrew packages
run: brew install just awscli
run: brew install just awscli macmon
- name: Install UV
uses: astral-sh/setup-uv@v6
@@ -238,92 +239,10 @@ jobs:
# Export keychain path for other steps
echo "BUILD_KEYCHAIN_PATH=$KEYCHAIN_PATH" >> $GITHUB_ENV
# ============================================================
# Pre-flight credential / profile validation
# Runs BEFORE the ~16 min build so auth/expiry failures surface in <1 min.
# ============================================================
- name: Validate Apple notarization credentials
env:
APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }}
APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
APPLE_NOTARIZATION_TEAM: ${{ secrets.APPLE_NOTARIZATION_TEAM }}
run: |
# All-or-nothing: either all three creds are set, or none are.
CRED_COUNT=0
for v in "$APPLE_NOTARIZATION_USERNAME" "$APPLE_NOTARIZATION_PASSWORD" "$APPLE_NOTARIZATION_TEAM"; do
[[ -n "$v" ]] && CRED_COUNT=$((CRED_COUNT + 1))
done
if [[ "$CRED_COUNT" -eq 0 ]]; then
echo "No notarization credentials configured — skipping notarization for this build."
exit 0
fi
if [[ "$CRED_COUNT" -ne 3 ]]; then
echo "ERROR: partial notarization credentials set ($CRED_COUNT/3). Aborting before build."
exit 1
fi
# Cheap, ~5s, auth-only call. Fails instantly with a clear message if
# the app-specific password is stale, wrong team-id, etc.
echo "Verifying Apple notarization credentials via notarytool history..."
if ! xcrun notarytool history \
--apple-id "$APPLE_NOTARIZATION_USERNAME" \
--password "$APPLE_NOTARIZATION_PASSWORD" \
--team-id "$APPLE_NOTARIZATION_TEAM" >/dev/null; then
echo "ERROR: notarytool rejected the provided credentials. Fix before rerunning."
echo "Common causes: app-specific password expired/revoked, wrong team-id,"
echo "Apple ID not on the team, or 2FA not configured for this Apple ID."
exit 1
fi
echo "Apple notarization credentials OK."
- name: Validate provisioning profile expiry
run: |
PROFILE="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/EXO.provisionprofile"
if [[ ! -f "$PROFILE" ]]; then
echo "ERROR: provisioning profile not found at $PROFILE"
exit 1
fi
EXPIRY=$(security cms -D -i "$PROFILE" | plutil -extract ExpirationDate raw -o - - 2>/dev/null || true)
if [[ -z "$EXPIRY" ]]; then
echo "WARNING: could not read ExpirationDate from provisioning profile; skipping expiry check."
exit 0
fi
# Try a couple of known plutil date formats. If none parse, skip the check rather
# than risk a false-positive "expired" block on a format we didn't anticipate.
EXPIRY_EPOCH=""
for fmt in "%Y-%m-%dT%H:%M:%SZ" "%Y-%m-%d %H:%M:%S %z" "%Y-%m-%d %H:%M:%S +0000"; do
if parsed=$(date -j -f "$fmt" "$EXPIRY" +%s 2>/dev/null); then
EXPIRY_EPOCH="$parsed"
break
fi
done
if [[ -z "$EXPIRY_EPOCH" ]]; then
echo "WARNING: could not parse ExpirationDate '$EXPIRY'; skipping expiry check."
exit 0
fi
NOW_EPOCH=$(date +%s)
if [[ "$EXPIRY_EPOCH" -le "$NOW_EPOCH" ]]; then
echo "ERROR: provisioning profile expired on $EXPIRY. Regenerate it before rerunning."
exit 1
fi
DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))
echo "Provisioning profile valid until $EXPIRY ($DAYS_LEFT days remaining)."
if [[ "$DAYS_LEFT" -lt 14 ]]; then
echo "WARNING: profile expires in under 14 days — regenerate soon."
fi
# ============================================================
# Build the bundle
# ============================================================
- name: Add pinned macmon to PATH
run: |
MACMON_DIR=$(nix develop --command sh -c 'dirname $(which macmon)')
echo "Using macmon from: $MACMON_DIR"
echo "$MACMON_DIR" >> $GITHUB_PATH
# Remove any Homebrew macmon so PyInstaller can't accidentally pick it up
brew uninstall macmon 2>/dev/null || true
- name: Build PyInstaller bundle
run: uv run pyinstaller packaging/pyinstaller/exo.spec
@@ -346,6 +265,7 @@ jobs:
EXO_BUILD_COMMIT="$GITHUB_SHA" \
SPARKLE_FEED_URL="$SPARKLE_FEED_URL" \
SPARKLE_ED25519_PUBLIC="$SPARKLE_ED25519_PUBLIC" \
EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT="$EXO_BUG_REPORT_PRESIGNED_URL_ENDPOINT" \
CODE_SIGNING_IDENTITY="$SIGNING_IDENTITY" \
CODE_SIGN_INJECT_BASE_ENTITLEMENTS=YES
mkdir -p ../../output
@@ -378,41 +298,11 @@ jobs:
APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
APPLE_NOTARIZATION_TEAM: ${{ secrets.APPLE_NOTARIZATION_TEAM }}
run: |
set -o pipefail
cd output
security unlock-keychain -p "$MACOS_CERTIFICATE_PASSWORD" "$BUILD_KEYCHAIN_PATH"
SIGNING_IDENTITY=$(security find-identity -v -p codesigning "$BUILD_KEYCHAIN_PATH" | awk -F '"' '{print $2}')
# Fail fast if notarization creds are partial. All-or-nothing.
CRED_COUNT=0
for v in "$APPLE_NOTARIZATION_USERNAME" "$APPLE_NOTARIZATION_PASSWORD" "$APPLE_NOTARIZATION_TEAM"; do
[[ -n "$v" ]] && CRED_COUNT=$((CRED_COUNT + 1))
done
if [[ "$CRED_COUNT" -ne 0 && "$CRED_COUNT" -ne 3 ]]; then
echo "ERROR: partial Apple notarization credentials set ($CRED_COUNT/3). Aborting."
exit 1
fi
/usr/bin/codesign --deep --force --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" EXO.app
# Pre-flight: verify the signed app BEFORE building DMG and submitting to Apple.
# If this fails, notarization will fail too — cheap way to fail in seconds, not 15 minutes.
echo "===== codesign --verify EXO.app ====="
if ! /usr/bin/codesign --verify --deep --strict --verbose=2 EXO.app; then
echo "ERROR: EXO.app failed codesign verification. Dumping signing status of every executable:"
find EXO.app -type f \( -perm -111 -o -name "*.dylib" -o -name "*.so" -o -name "*.framework" \) -print0 |
while IFS= read -r -d '' f; do
printf -- '--- %s\n' "$f"
/usr/bin/codesign -dv --verbose=2 "$f" 2>&1 | sed 's/^/ /' || true
done
exit 1
fi
# Gatekeeper assessment. A failure here strongly predicts notarization rejection.
echo "===== spctl assessment (predicts notarization outcome) ====="
/usr/bin/spctl -a -vvv -t install EXO.app || echo "WARNING: spctl assessment failed — notarization is likely to fail too."
mkdir -p dmg-root
cp -R EXO.app dmg-root/
ln -s /Applications dmg-root/Applications
@@ -420,22 +310,12 @@ jobs:
hdiutil create -volname "EXO" -srcfolder dmg-root -ov -format UDZO "$DMG_NAME"
/usr/bin/codesign --force --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$DMG_NAME"
echo "===== codesign --verify DMG ====="
if ! /usr/bin/codesign --verify --verbose=2 "$DMG_NAME"; then
echo "ERROR: DMG failed codesign verification."
exit 1
fi
if [[ -n "$APPLE_NOTARIZATION_USERNAME" ]]; then
echo "===== notarytool submit ====="
# `|| true` so set -e doesn't abort before we can echo output / fetch the log.
# We rely on the parsed STATUS below to decide pass/fail.
SUBMISSION_OUTPUT=$(xcrun notarytool submit "$DMG_NAME" \
--apple-id "$APPLE_NOTARIZATION_USERNAME" \
--password "$APPLE_NOTARIZATION_PASSWORD" \
--team-id "$APPLE_NOTARIZATION_TEAM" \
--wait --timeout 15m 2>&1) || true
--wait --timeout 15m 2>&1)
echo "$SUBMISSION_OUTPUT"
SUBMISSION_ID=$(echo "$SUBMISSION_OUTPUT" | awk 'tolower($1)=="id:" && $2 ~ /^[0-9a-fA-F-]+$/ {print $2; exit}')
+3
View File
@@ -91,6 +91,9 @@ jobs:
nix build .#metal-toolchain
fi
# Build mlx (depends on metal-toolchain)
nix build .#mlx
- name: Build all Nix outputs
run: |
nix flake show --json | jq -r '
+1 -6
View File
@@ -18,6 +18,7 @@ digest.txt
app/EXO/build/
dist/
# rust
target/
**/*.rs.bk
@@ -37,9 +38,3 @@ bench/**/*.json
# tmp
tmp/models
/build/exo
/.agents
/.claude/skills
/.claude
/.codex
skills-lock.json
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="FacetManager">
<facet type="Python" name="Python facet">
<configuration sdkName="Python 3.13 virtualenv at ~/Desktop/exo/.venv" />
</facet>
</component>
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/scripts/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/rust/exo_pyo3_bindings/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/rust/exo_pyo3_bindings/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/rust/util/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/rust/networking/examples" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/rust/networking/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/rust/networking/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/rust/system_custodian/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" />
<excludeFolder url="file://$MODULE_DIR$/.direnv" />
<excludeFolder url="file://$MODULE_DIR$/build" />
<excludeFolder url="file://$MODULE_DIR$/dist" />
<excludeFolder url="file://$MODULE_DIR$/.go_cache" />
<excludeFolder url="file://$MODULE_DIR$/rust/target" />
</content>
<orderEntry type="jdk" jdkName="Python 3.13 (exo)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="Python 3.13 virtualenv at ~/Desktop/exo/.venv interpreter library" level="application" />
</component>
</module>
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalDependencies">
<plugin id="al.aoli.intellijdirenv" />
<plugin id="systems.fehn.intellijdirenv" />
</component>
</project>
+14
View File
@@ -0,0 +1,14 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyCompatibilityInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ourVersions">
<value>
<list size="1">
<item index="0" class="java.lang.String" itemvalue="3.14" />
</list>
</value>
</option>
</inspection_tool>
</profile>
</component>
+2 -2
View File
@@ -4,7 +4,7 @@
<option name="sdkName" value="Python 3.13 (exo)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13 (exo)" project-jdk-type="Python SDK" />
<component name="RuffConfiguration">
<option name="enabled" value="true" />
<component name="PythonCompatibilityInspectionAdvertiser">
<option name="version" value="3" />
</component>
</project>
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 729 files, more files were not shown because too many files have changed in this diff. Show more