round 39 opus review — v0.1.45 serve/tokenizer/ple ports + astra qsa patch #
read-only review. no source edits, gpu runs or service actions by me. scope: prompt cache, logit_bias cache,
telemetry fallback, ple dedup, astra qsa patch. paths are relative to vendor/strata/ unless noted.
what i compared #
/tmp/v0145_serve_ple.patchapplied to a cleangit archive HEADcopy (/tmp/r39_audit_tree, exit 0). the patch does not apply to the dirty tree (serve/server.py:3852conflicts with the localSTRATA_OMIT_IMAGES_WHEN_NO_VISIONblock); that fullgit applyfailed atomically, so nothing in it landed. the serve/tokenizer parts went in through the serve owner (working-tree server.py = the clean-applied copy plus the local omission block). PLE went in as only upstream commit 36381540 (results/round39/ple.patch: ngram.cpp + ple_reader_test.cpp, no mutex). the later upstream commit that adds thestd::recursive_mutexwas not imported; the mutex was never in our tree, so nothing was dropped or reverted./tmp/astra_r39_qsa/astra_r39_qsa.patchagainst currentmetal/.
verdict #
no remaining correctness blocker once the logit_bias _last removal (already done by the serve owner) is in.
three should-fix items before benchmark numbers or a persona eval are attributed to this build: telemetry
cost on darwin (S1), answer_room default (S2), prompt-cache cap (S3).
should-fix #
S1. telemetry runs ioreg and creates a Metal device in the server every second (darwin) #
serve/server.py:6485serve()always callsstart_telemetry(); on darwingpu_readernow returns_Metal(serve/telemetry.pygpu_reader), so the sampler thread runsioreg -l -w0 -r -c IOAcceleratorevery second (measured here on the M5: 39 ms wall, 57 KB of output; the patch says 22 ms) and_Metal.read()callsmetal_working_set()on every sample, which doesMTLCreateSystemDefaultDevice()(create rule: +1 retain that is never released) and the objc lookups each time. before the port, darwin had no gpu reader, so this is new work in the serving process during decode.- fix: compute the working set once (
_Metal.__init__) and reuse it inread()and inTelemetry.static. - test: patch
telemetry.metal_working_setwith a counter, build_Metal(run=fixture), callread()3x, assert 1 call. before citing any tok/s from this build: paired decode A/B with the sampler on vsgpu_readerforced to_Nvml(same engine, same prompts). - semantics (not a blocker; it only matters for opt-in
min_free_vram_mib/ image policy): on this M5 at idle"In use system memory"=15007744(15 MB) vs"Alloc system memory"=15473033216(15.5 GB). free = working set − in-use probably reports far more free than there is while the wired arena is up. needs one check with the engine loaded before anyone turns onmin_free_vram_mibon the mac.
S2. answer_room is on by default and changes every thinking request on our config #
serve/server.py:4206-4214: with noreasoning_budget_tokens, every thinking request withmax_new >= 1024gets budget = max_new − max(512, max_new/4). our config hasfit_max_tokens: trueand 32K context, so a request without max_tokens gets max_new = room and a budget of 75% of it, plus aprinton every request (max_new <= 32768is always true at 32K ctx).- when it fires,
REASONING_WRAP_UP("I have thought about this long enough; time to give my answer.") is injected into the reasoning stream, and the generation splits into a second engine pass.frontend.pyadditionally turns Anthropicthinking.budget_tokensinto a hard budget. - effort
lowkeeps this rare, but persona/antislop evals before and after the port are not comparable unless this is pinned. decide explicitly:"answer_room": falseinmodels/K512_iq2_xs/strata-k512-iq2xs.jsonfor parity runs, or accept and note it. the per-request print should go behind a once-flag either way. - model-card sampling (
model_card_sampling): checked: a no-op for our config, because thesamplingblock sets temperature, top_p, top_k and presence_penalty, which covers every key in both qwen4exp sets.
S3. PromptEncoder keep has no upper bound #
serve/server.pyService init:keep = conversation_slots(engine)is taken from--conversation-cache-slotswith no clamp; the engine accepts up to INT32_MAX (src/program/generate.cpp:2113). each entry holds the full prompt str (1–4 B/char: kaomoji/CJK push the str to UCS-2/4), its ids list (8 B/token) and its mark lists, so ~2–5 MB at 100K tokens. our config passes no slots, so keep = 4 and this is latent.- fix:
keep=min(slots, 16)(or a total-chars budget). test:conversation_slotsstub returning 10_000 → assertsvc.prompts.keep <= 16; feed 20 unrelated prompts →len(entries) <= keep.
checked and fine #
prompt cache exactness / mutation / concurrency (tools/strata_tokenizer.py PromptEncoder) #
- the margin argument holds for
_encode_matching: a match decision at q depends only ontext[q:q+max_special_len]and on whether q is in a plain span; the finditer state before a cut c (end of a taken match) is identical whenc + max_special_len - 1 <= same.call_end-style lookbehind does not exist in the literal alternation. - fuzz (
/tmp/r39_fuzz.py,/tmp/r39_fuzz2.py, real Swift tokenizer frommodels/K256_reap_l2_sum/tokenizer, 33 specials, max len 21): conversations with appends, mid-inserts, truncations, specials split across appends (<|im_sta+rt|>), random plain spans,store=Falsemixed in, keep 1/2/4. 38,033 cases, 8,529 of them reusing a prefix, 0 mismatches vstok.encode(text, parse_special=True, plain=...). - mutation:
encode(store=True)returnslist(ids),store=Falsereturns a list that is not stored, entries are replaced and never changed in place;preparenever mutatesidsin place (images buildout, force builds a new list), soforget_prompt(encoded_ids)matches. the local omission block runs beforeencode_prompt(serve/test_image_omission.pycovers it). - concurrency: snapshot under the lock, BPE outside it, replace under the lock against the current list: a race loses reuse at worst, never data.
pick_slotTURN_ID = 248045matches<|im_start|>in our vocab.- parser
_wrapped_call_endfast path: sound, becausecall_endonly ever returns positions of a literal</tool_call>, and_call_end_checkedis reset on every path that leaves thecallstate.
logit_bias (serve/logit_bias.py) #
_lastidentity+len staleness: found by the parent, already removed by the serve owner.- content key
json.dumps: no collisions that change the result:{1: x}and{"1": x}serialize alike and normalize alike; float/bool keys, NaN and inf never normalize, so they are never stored;Falsevs0and1vs"1"serialize differently. results are copied (normalize→dict(...)), and the engine key is an immutable str. the lazyentry[1]write is a benign race (same string). - cost note only: without
_last, a 65K-entry list paysjson.dumpson each of the ~5 calls per request. compute the key once in the route if that shows up in profiles. - note: the Metal engine does not report
logit_bias=1, sovalidate_requestrefuses non-empty biases on our backend anyway.
PLE dedup (src/kernels/ngram.cpp gather_batch) #
- any-slot matching plus
matched[t]reuse is correct (keys are row ids; the table is immutable). droppingn_prefetch >= n_tokensis correct now that duplicates map to one slot. draining every ticket before returning an error fixes the old early return, which left tickets in flight writing into slots that would be reused. - Metal uses Direct mode (
metal/src/strata_window_cli.cpp:945), so this path is live: MTPprefetch_rows(metal/src/engine/window.cpp:2972) feeds the verify window'sgather_batch(T = spec+1 ≤ 16). prompt chunks (2048) take the slow path, which drops the prefetches; that is fine. - the later upstream
recursive_mutexcommit was not imported (only 36381540 was transplanted), so the table's threading is unchanged from our baseline. on Metal that baseline is fine:ple_th(window.cpp:3434) is joined inside the forward, and the MTP prefetch runs after it, so they never overlap. other backends (the desktop HIP build shares this file; upstream's mutex comment is about prompt-path / decode-window overlap there) carry the same unchanged baseline hazard and are untested in this round. record it in the port notes as "not imported". even with the mutex,issue()copies intoimpl_->rowsbefore itspendingcheck (pre-existing), so a secondissue()while one is in flight corrupts the first ticket's keys: move thependingcheck first if this ever becomes multi-threaded.
astra qsa patch #
qsa_merge2is dispatched only bymetal/tests/token_chain.cpp:249andmetal/tests/qsa_parity.cpp:49,159,254. the engine dispatchesqsa_merge(v1) atmetal/src/engine/window.cpp:1285,2737,3662andmetal/src/engine/prompt_chain.inc:262.qsa2.metalis still compiled intolqsa_(window.cpp:763), so the change is compile-surface only: zero runtime effect, no end-to-end attribution. the unroll keeps the fma accumulation order (it only hoists loads), which matches astra's bitwise result.qsa_prompt.metalclamped-row edits are in the!coop_k4branches only; on the M5,Window::coop_k4()probes true, so they run only withCOOP_K4=0or on M1/M4. it is a safety port, with no M5 default-path effect. row 0 / scale 0 for empty cells is in bounds whenever the cache holds ≥1 cell.moe_mm_parityTK=64/v2||v3: test-only. the baseline default was already broken (missingmm_gu_42_16_64_16_2, exit 3).
tests i ran (cpu only, PYTHONDONTWRITEBYTECODE=1, from vendor/strata) #
python -m unittest serve.test_prompt_encoder serve.test_logit_bias serve.test_image_omission serve.test_model_card_sampling serve.test_admit_order: 33 tests OK (before the serve owner's_lastfix landed).python -m unittest serve.test_server.MacTelemetry serve.test_server.AmdTelemetry: 7 OK.python tools/test_strata_tokenizer.py: 11 OK, 3 skipped.- PromptEncoder fuzz above: 0 mismatches.
- not run: anything on the gpu, ple_reader_test (the parent ran it), the full serve suite.
followup v2 — sdk ownership check for S1 #
MacOSX.sdk/System/Library/Frameworks/Metal.framework/Headers/MTLDevice.h(CommandLineTools sdk on this machine):MTL_EXTERN id <MTLDevice> __nullable MTLCreateSystemDefaultDevice(void) API_AVAILABLE(macos(10.11), ios(8.0)) NS_RETURNS_RETAINED;so the caller owns +1, and each uncachedmetal_working_set()call leaked one retain on the device singleton. that is a refcount, not memory per call, and the cost is the per-sample framework call. with the working set cached once, a single unreleased +1 at startup is acceptable. an optionalobjc_msgSend(dev, sel_registerName("release"))after reading would make it exact, but it is not needed.- the same header says a call switches a dual-gpu intel mac to the high-power gpu. it does not apply to apple silicon, but it is one more reason to call it once and not every second.
- answer_room: parent approved keeping the live default; it stays disclosed in S2, with no comparable eval numbers claimed.
followup v3 — hardening review (final sources) #
- the fingerprints in
results/round39/integration-source-sha256.jsonmatch the working tree for all 27 files. - telemetry:
_Metal.__init__reads the working set once (self.working_set), and bothread()andTelemetry.staticreuse it.metal_working_set()releases the device in afinally(objc_release, matching theNS_RETURNS_RETAINEDannotation); the earlyif not devreturn happens before the try, so a null is never released. covered bytest_working_set_is_read_once_for_samples_and_staticplus the release assertion. what remains per sample: the 1 Hzioregsubprocess (39 ms wall measured earlier); its cost on decode is still not A/B measured.Service.free_vram_mib()still builds a fresh reader per call (2 ioreg runs + 1 working-set read), but only on the opt-in and image paths. - prompt cache:
PROMPT_ENCODER_KEEP_MAX = 16caps slots from args and from INFO, and the default stays 4. covered byIncrementalPrompts(asserts 10**9 → 16 on both paths).tools/strata_tokenizer.pyis byte-identical to the fuzzed version, so the 38,033-case result still applies to the final source. - logit_bias:
_lastis fully removed and only the bounded JSON-keyed cache remains; nothing else changed. - reran on the final sources (cpu): test_prompt_encoder, test_logit_bias, test_image_omission, MacTelemetry, AmdTelemetry: 33 OK; IncrementalPrompts: 9 OK.
- verdict: S1 (per-sample device creation) and S3 (cap) are resolved. S2 is accepted and disclosed by the parent.
followup v4 — provenance of results/round39/model-ab/ (read-only; no processes launched) #
ngram-baseline.cppis byte-identical togit show HEAD:src/kernels/ngram.cpp(ngram.cpp was clean before the round). against the current file the diff is 15+/11-, i.e. the 36381540 dedup only.- host archive: 34 members, same names and order as
build-metal/libstrata_metal_host.a. extracted and hashed: onlyngram.cpp.oand the ranlib__.SYMDEFindex differ, and the swapped member equalsmodel-ab/ngram.cpp.o. no other linked archive definesPleTable::gather_batch(libstrata_metal_kernels.aonly references it), so link order cannot pick a stale copy. the disassembly ofPleTable::gather_batchdiffers betweenbaseline-cli(340 lines) andcandidate-cli(391 lines). candidate-cliis byte-identical tobuild-metal/strata_window_cli, built at 11:59:22, after the last round39 source write (qsa files 11:59:21, ngram.cpp 11:56:32).- shaders: the runtime directory comes from
STRATA_METAL_KERNELS(metal/src/mtl_rt.mm:51), with no on-disk library cache in mtl_rt.candidate-kernels/equals the currentmetal/kernels/file for file (22/22).baseline-kernels/differs from it only inqsa2.metalandqsa_prompt.metal, matches/tmp/astra_r39_qsa/baselineexactly, and matches the sha256 values in astra'smetadata.jsonfor qsa/qsa2/qsa_prompt. the engine-private msl (mtp.metal,mtp_chain.metal,ple.metal,slop4.h) loads from the compiled-in absolute path for both arms; all four were last written before the round (02:57–08:11, and slop4.h on 10-17), so this is shared and unchanged. - consequence for interpreting results: on the M5 the shader delta is inert (
qsa_merge2is not dispatched, and the non-coop branches are not taken), so the arms differ in effect only by the PLE gather_batch dedup in the MTP verify path. - suggestions, not blockers: (1) both arms should emit identical token ids, because the dedup returns the same bytes; compare the generated ids per pair as a correctness gate. (2) the 10K case decodes ~31 tokens in ~0.9 s, which is too short a window to resolve a 2% decode delta with 3 pairs; call that case's decode inconclusive unless the spread is clearly under 2%, and read prefill separately.
followup v5 — reading the paired CLI A/B (logs only: results/round39/model-ab/runs.json, summary.json) #
amended at the parent's request: everything below is descriptive. nothing here is timed beyond what runs.json records, and no statistical bound is claimed.
- output token IDs equal in all 5 measured pairs (and the warm pair). aggregate
STRATA_SPECrounds/drafted/accepted are equal within each case for every run of both arms (10K: 13/23/18; 2K: 39/99/88). equal aggregates support comparable work; they do not establish an identical acceptance trajectory, which is not logged and is not inferred from the output IDs. - drift: decode ms/token rises overall through the session, 28.65 → 33.05 over the 10K series, but not monotonically (10K decode 920.70 ms, then 915.71 ms in consecutive runs; 2K 4213.72 then 4143.13). it stays at 31.5–33.2 in the 2K series. 10K prefill goes 21.6 s → 30.4 s. this affects both arms. sustained-load throttling on a fanless Air is plausible but unmeasured (no thermal readings), so no cause is claimed.
- descriptive drift-adjusted fit (OLS: value ~ run index + arm, computed from runs.json; n = 6 and n = 4, so no
confidence intervals are claimed):
- 10K decode: −5.9 ms (−0.6%), residuals up to ±22 ms.
- 10K prefill: −431 ms (−1.7%), residuals up to ±1.0 s.
- 2K decode: +64.6 ms (+1.6%), residuals ±49 ms, 1 dof.
- 2K prefill: +210 ms (+3.2%), residuals ±232 ms, 1 dof.
- prefill as a same-path comparison: prompt chunks call
gather_batchwith T = 2048 (one 1984-token chunk for 2K; 4×2048 + 1988 for 10K). old and new code take the same slow path for T > 16, and the shader changes are not executed on the M5 default path. the prefill arm differences nonetheless have opposite signs (−1.7%, +3.2%). this shows the run-to-run instability of this setup; it is not a statistically established noise floor. - code-path note (an operation count, kept separate from the measurements; it does not enter the verdict): in decode, the verify windows call
gather_batchwith T ≤ 4. the new matching adds at most 4×16 key comparisons of 64 B each and issues no more SSD read tickets than the old code (its fast path covers every case the old fast path did; the non-fast path is unchanged). its latency was not timed. - verdict: inconclusive for small performance changes. no consistent slowdown is visible in 10K; the 2K decode
differences (+1.5–1.7% per pair) cannot be separated from the drift and instability in this run. a cooled, stationary
experiment would be needed to resolve differences at this scale. the parent's wording in
audits/r39_integration.mdmatches this.