perf(text): skip NFC normalisation when the text is already pure ASCII - #205
Merged
Merged
Conversation
normalize_nfc hands every prompt to utf8proc_map, which decodes, maps and recomposes the whole text into a fresh allocation. Every byte below 0x80 is a starter with canonical combining class zero that takes part in no canonical composition, so pure ASCII is already in NFC and none of that work changes it. One linear pass decides the question, and text that is not ASCII leaves on its first high byte, so the check costs nothing where it does not apply. The test looks at raw bytes rather than decoded codepoints, and that is what makes it safe: every byte of a multi-byte UTF-8 sequence is at or above 0x80 - lead bytes 0xC2..0xF4, continuation bytes 0x80..0xBF - so no non-ASCII input can reach the fast path, well-formed or not. The rejection contract is unchanged for the same reason: invalid UTF-8 necessarily contains such a byte and still goes to utf8proc_map. Measured on the tree's own normalize_nfc and Tokenizer::encode, with the product tokeniser read out of a qwen3_6_35b_a3b.ninfer artifact (248044 tokens, 247587 merges), arms interleaved inside a round, one process per point, 3 warm-ups and 15 timed repeats, cell is the median over 7 rounds of the minimum of 15, two independent passes. On pure-ASCII prompts normalize_nfc falls to 0.014..0.016 of its cost - 0.7948 ms to 0.0111 ms on a 32K-token chat - and host encode to 0.773..0.866 over nine such fixtures. The probe was re-run on 487f897 with the same protocol and the multiplier did not hold: it reads 0.0175..0.0216, i.e. x46..x57, against the x62..x71 above, over eight pure-ASCII fixtures with a null control at 0.968..1.031. encode re-reads 0.776..0.870 on the same fixtures, so the -13..-22% is the part that reproduced. The input is not the variable: src/text/unicode.cpp is the same blob on both bases (193b92a...) and utf8proc is unchanged, so both runs compiled byte-identical sources. What changed between them is the compiler and the host together, g++ 13.3.0 against g++ 15.2.0 on different silicon, and the ratio is set entirely by how fast the base arm scans. That is a plausible account and not a measurement: the two were never separated, because g++ 13 is not installed on the machine that has 15, so no A/B over the compiler alone was run. This message quotes x46..x57 and treats x62..x71 as a figure of one particular host. On prompts carrying non-ASCII bytes the operator over the whole text is 0.947..1.025, inside the band that 54 cells of provably identical machine code occupy across separately linked binaries (0.902..1.025, md5 of unicode.o equal by pair); host encode on those prompts ranges from 0.923, where most segments are still ASCII, to 0.990..1.000 where almost none are. The worst case a prompt can construct - ASCII for its whole length with one high byte at the very end, so the scan walks all 32 KB and leaves empty-handed - is 0.984..1.011, below this bench's resolution. Normalisation runs per stretch of ordinary text between added tokens rather than once per prompt, so the fast path fires more often than a whole-document ASCII test suggests: a chat fixture that is 27% non-ASCII by byte reads 1.019 on the operator over the whole document and 0.923 on encode. End to end the effect follows how much host work is left in the step, and both regimes were measured. On a cold prompt host preparation is 0.9..1.3% of the run and this is worth about -0.3% of TTFT. On a fully hit prefix cache tokenisation is 79..81% of TTFT and this is worth 4.9..14.8% of it. That second regime is measured with apps/ninfer-serve, not with ninfer_bench: the server ships prefix reuse on (src/serve/serve_options.h:50, allow_prefix_reuse = true, byte-identical on both bases) and the run passes no --no-prefix-reuse, and its request log shows the hits actually happened - computed_prefill_tokens=0, prefix_cache_hit_tokens=8475 on requests 2..40. ninfer_bench hard-codes allow_prefix_reuse = false (bench/targets/qwen3_6_27b/ninfer_bench.cpp:65) on both bases, so no bench number here is a prefix-hit number and none is offered as one. Token ids are unchanged. normalize_nfc(s) == s on every ASCII string of length 1, 2 and 3 including 0x00 and every control character - 2113664 of them - plus 2000000 random ones of length 4..32: 4113664 strings, no mismatch and no exception raised by the unpatched function. Output digests over 18 fixtures at seven max_tokens values in two added-token modes, 252 cells, are byte-identical between arms, as is an exhaustive max_tokens sweep of 0..3000 over 8 fixtures, 24008 encodes per arm. Base and provenance. This change now targets upstream a16b644 (docs: organize performance reports and update 35b results). Its parent 487f897 is the commit the rebase and the probe re-measurement above were carried out on; a16b644 touches thirteen documentation files and no source, header, test or bench file, so every citation and number here reads the same on both. The work was written and measured on ad0f3d3. The normalize_nfc probe is the one measurement repeated on the new base; nothing else in this message has been - not the encode column, not the end-to-end rows, not the server TTFT rows, not the identity corpus. Two instrument changes came with the rebase. The bench flag --mtp-draft-tokens no longer exists: the spelling is --spec mtp --draft-tokens N, and --draft-tokens 0 is rejected for MTP (src/product/speculative_options.h:41), so the zero-draft arm is spelled by passing neither flag. And ninfer_bench now disables the engine context cache itself (bench/targets/qwen3_6_27b/ninfer_bench.cpp:157), where on ad0f3d3 that line did not exist and the cache stayed at its enabled default. Per-request prefix reuse was already off on both bases (same file, line 65), so no request here could take a prefix hit; how far a constructed-but-unused cache reaches into prefill and TTFT has not been measured, and these figures describe a configuration the current bench no longer builds. tests/CMakeLists.txt registers 114 targets on 487f897 rather than 104, with 20 named targets explicitly artifact-gated rather than 19; no ctest run quoted here was repeated on the new base, and no round "114 of 114" is claimed for it: ninfer_attn_input_proj_test is red on the bare base (upstream defect, issue Neroued#196), and ctest and a direct run of the same binary have been seen to disagree on this host.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Scope
One host function,
ninfer::text::normalize_nfc, one file,src/text/unicode.cpp, +12 lines,-0. No signature, no header, no public contract, no CLI flag, no workspace, no graph profile, no
artifact format, no device code. Token ids are bit-identical; the change is a fast path, not a
different answer.
Level of the claim: host operator, with the caller measured on both sides of it. The operator
number is large and easy; the end-to-end number is the honest part, because it depends entirely on
whether there is any prefill left to hide behind. Both regimes are measured and both are below.
Environment
Two hosts. Nothing in this report mixes them, and every table says which one it comes from.
Measurement host. AMD EPYC 7B12, 64 cores / 256 threads, 503 GiB, one RTX 5090; Ubuntu 24.04,
g++ 13.3.0, Release,
-DCMAKE_CUDA_ARCHITECTURES=120a, CUDA 13.1.115. Baseorigin/mastera140e7ae; four arms built from one tree in turn,git am --3wayclean, tree not dirty, zero debugmarkers.
Test host. AMD EPYC 9V74, 160 threads, 188 GiB, one RTX 5090, driver 580.95.05, CUDA 13.1.115,
g++ 13.3.0, Ubuntu 24.04.4, cmake 3.28.3, ninja 1.11.1. This is where
ctestwas run, because thesuite links one static executable per test and needs the disk for it.
Vocabulary is the product one, not a stand-in: the tokenizer was read out of a
qwen3_6_35b_a3b.ninferartifact - 248 044 tokens, 247 587 merges, 26 added tokens. The operatorrows do not involve a vocabulary at all; the
encoderows do, and this is the real denominator.Method for every ratio below. One process per (arm, fixture, round); arms interleaved inside a
round; 3 warm-ups then 15 timed repeats; the cell is the median over 7 rounds of the minimum of
those 15; two fully independent passes, both printed. Server rows are 40 requests per arm per
pass over 16 sessions with
--request-log-jsonlat full precision.The noise floor is measured, not asserted. The same sweep contains 54 cells whose true value
is exactly 1.000, because in those pairs the arm does not touch
src/text/unicode.cppandmd5sumshowsunicode.obyte-identical between the two binaries. Those 54 cells run0.902 to 1.025, median 0.997, and the low end reproduces in both passes on the same fixture, so
it is binary layout rather than the scheduler. Within-arm dispersion of round minima on the base arm,
72 cells: median 1.40%, p90 3.14%, max 8.82%. Working rule: anything inside 0.90..1.03 on this
bench is not an effect. Every number claimed as an effect below is far outside it - 0.014 is seven
times further from 1.000 than the edge of the band.
Observation
normalize_nfcis 0.79 ms of a 5.86 msTokenizer::encodeon a 33 KB pure-ASCII chat prompt, and2.87 ms on a 135 KB one. It is called on every prompt, on every request, before anything reaches the
GPU. On an artifact where a hit prefix cache brings prefill down to 1.6 ms, that host work is
79 to 81% of time-to-first-token.
Root cause
src/text/unicode.cpp:18hands every prompt toutf8proc_mapwithUTF8PROC_STABLE | UTF8PROC_COMPOSE, which decodes the text to codepoints, maps each one,recomposes, and writes the result into a fresh
malloc'd buffer that the function then copies into astd::stringand frees.Every byte below
0x80is a starter with canonical combining class zero that takes part in nocanonical composition. Pure ASCII is therefore already in NFC, and the entire pass is the identity on
it - decode, map, recompose, allocate, copy, free, to reproduce the input.
Change
One linear pass over the bytes; if none is
>= 0x80, return a copy and skiputf8procentirely.The test is on raw bytes, not on decoded codepoints, and that is what makes it safe. Every byte
of a multi-byte UTF-8 sequence is
>= 0x80- lead bytes0xC2..0xF4, continuation bytes0x80..0xBF- so any non-ASCII input, well-formed or not, leaves the loop on its first such byte andtakes the unchanged path. The classification can only be conservative: it cannot admit a non-ASCII
string to the fast path. The rejection contract is unchanged for the same reason - invalid UTF-8
necessarily contains such a byte, still reaches
utf8proc_map, and still throws the same exception.Nothing is added to any object's lifetime: no
getenv, nothread_local, no module-global state.The fast path replaces the
malloc/freepair insideutf8proc_mapwith thestd::stringcopy thefunction already performed on its way out.
Correctness evidence
The premise is checked exhaustively against the tree's own vendored
utf8proc, not argued.normalize_nfc(s) == sfor every ASCII string of length 1, 2 and 3 - all 2 113 664 of them,including
0x00and every control character - plus 2 000 000 random ones of length 4..32. Total4 113 664 strings: 0 mismatches, 0 exceptions raised by the unpatched function. Two consequences
worth naming:
STABLE | COMPOSEcarries noNLF2LF, so CR/LF and the C0 controls are fixed points;and
utf8proc_mappasses an embedded NUL through rather than rejecting it, so a NUL in a promptbehaves the same on both paths.
Output is byte-identical. 18 fixtures - English prose, code, Russian, Chinese, emoji and ZWJ
joins, malformed boundaries, control runs - at seven
max_tokensvalues in both added-token parsingmodes: 252 digest cells, byte-identical on all four arms. An exhaustive
max_tokenssweep of0..3000 on 8 fixtures, 24 008 encodes per arm, gives an identical rolling digest. The probes are
not vacuous: precomposed and decomposed spellings of the same text converge to the same normalised
digest, so composition really happens on the slow path.
ctest, run on both arms on the test host.a140e7ae076bd9bdRe-run with the real artifacts in the environment, so the six weight-gated tests actually execute:
a140e7ae27b_load_plan), 1 FAILED:ninfer_qwen3_6_27b_prefix_real_test-normalized first response did not restore its response checkpoint: path=5 reused=267076bd9bdThat failure is upstream's, not this branch's. It is on
a140e7aeitself with an unmodifiedtree, it reproduces identically on both arms down to
path=5 reused=267, and it has been reproducedon three separate machines. It is not touched, worked around, or hidden here; it is reported as a
separate defect.
A passing suite proves nothing until it is shown to look at the changed code, so that was
measured too. A throwaway probe branch (not part of this PR) nulls the added fast path -
if (ascii_only) { return std::string(); }- and the suite goes red:So the suite does observe this code path - through 12 distinct assertions - and the 104/104 above is
a real result rather than an absence of coverage. It is worth being precise about how thin that
coverage is: exactly one of the 104 tests dies under the probe. Changing
unicode.cpprelinks 23of the test executables, but only
ninfer_qwen3_6_frontend_testactually checks whatnormalize_nfcreturns.clang-format, repo.clang-format, version 23.1.0, counted as "lines the formatter wouldchange in
src/text/unicode.cpp": 0 before the change, 0 after. The file is clean, as is the oneit replaces.
Performance
Operator and caller, isolated
Ratios to base, pass 1 / pass 2. The
non-ASCII bytescolumn is the reason each row lands where itdoes.
normalize_nfcTokenizer::encodeasciien_chat_32k- ASCII chatasciien_plain_32k- ASCII, single turnchat_code_32k- chat with codeplain_code_32k- code, single turnchat_en_8k- short ASCII chatchat_unique_32k- ASCII, rare tokensunique_ascii_32k- ASCII, rarest tokensuniqrand_chat_32kuniqrand_plain_32kchat_en_32k- repo documentationchat_en_128k- same, longplain_en_32kplain_en_128kchat_mixed_32k- languages + emojichat_zh_32k- Chinesechat_ru_32k- Russiannonascii_only_32k- not one ASCII byteascii_then_high_32k- worst caseThe two
plain_en_*rows carry non-ASCII bytes - their operator ratio says so - but the exact countwas not recorded for them; every other row's count is exact.
Nine pure-ASCII fixtures land at 0.014..0.016, that is x62 to x71 - on
ad0f3d38, which is thebase this table was taken on. Re-measured on
487f8977the multiplier is x46 to x57; thesubsection below prints the re-run beside it. In absolute terms on
asciien_chat_32k,normalize_nfcgoes 0.7948 ms -> 0.0111 ms; onchat_en_8k,0.1990 -> 0.0029 ms. Host
Tokenizer::encodeon those same fixtures is 0.773 to 0.866, i.e.-13 to -23%.
The probe re-measured on the new base
Two arms built from the same
487f8977tree -basecarryingsrc/text/unicode.cppfrom the barebase, the arm carrying this branch's version - plus a copy of
baseas a null control. Six rounds,arms alternated between rounds, round 1 discarded, median over the remaining five of the minimum of
15 repeats, a fresh process per cell.
tokenizer.cppand the vendoredutf8procare the same blobin both arms (md5 equal).
prose_ascii_256kprose_ascii_64kcode_ascii_64kconv_ascii_64kconv_plain_64krepeat2_ascii_64kunique_ascii_64klongword_unique_64kx46 to x57, against the x62 to x71 claimed above. The non-ASCII fixtures behave as before and
stay inside the noise band -
russian_64k0.9750,nonascii_only_64k0.9739,cjk_only_64k1.0136,
mixed_64k0.9770, and the worst caseascii_then_high_64k0.9481, against a null controlof 0.9986 to 1.0240 on those same fixtures.
Tokenizer::encodere-reads 0.776 to 0.870 onASCII, i.e. -13 to -22%, which is the same statement as the -13 to -23% above.
Why the multiplier moved, as far as it can be established - and it cannot be established. The
input is not the variable:
src/text/unicode.cppis the same blob on both bases,193b92ad1e3267a27421b6dcaf482b36ad9bf426onad0f3d38and on487f8977alike, andthird_party/utf8procis unchanged as well, so the two runs compiled byte-identical sources. Whatdid change between them is the compiler and the host together - g++ 13.3.0 on the machine the
first table was taken on, g++ 15.2.0 on the machine the re-run was taken on, on different silicon.
The whole ratio is set by how fast the base arm scans, so a faster base arm shrinks the multiplier
without anything happening to this change at all, and both a newer compiler and a different CPU
would do that. That is a plausible account and not a measurement: the two were not separated,
because g++ 13 is not installed on the machine that has the newer one, so no A/B over the compiler
alone was run. Neither this report nor its author knows which of the two moved the number.
What this does and does not do to the claim. The mechanism is unchanged - a pure-ASCII string is
returned untouched instead of being decoded, mapped and recomposed - and it reproduced on two hosts,
two compilers and two fixture sets, with the null control flat in both. What is not stable across
hosts is the size of the multiplier, so this report quotes x46 to x57 and treats the old x62 to
x71 as a figure of one particular host. Anyone reproducing on a third machine should expect a third
number in that neighbourhood; the load-bearing statement is
Tokenizer::encodeat -13 to -22%, whichdid reproduce.
The nine rows that carry non-ASCII bytes are in the same table and the same runs, and the operator
does not move on any of them: 0.947 to 1.025, every cell inside the measured 0.902..1.025 band.
Their
encodecolumn is a different matter and is the next subsection: two of them do move, becauseencodenormalises per segment rather than per prompt.The worst case is measured rather than assumed.
ascii_then_high_32kis the most expensive shapea prompt can construct: ASCII for its whole length with a single high byte at the very end, so the
scan walks all 32 KB and buys nothing. It reads 1.011 / 0.984 - inside the band, i.e. below this
bench's resolution. An earlier and cruder measurement of the same shape put it at 1.8%; on the
product vocabulary with the noise floor measured, it does not resolve at all.
The fast path fires more often than a whole-prompt ASCII test suggests
This is the finding that changes how the applicability should be read. Look at
chat_en_32k: it is27% non-ASCII by byte (9 156 of 33 784), so
normalize_nfcover the whole document is 1.019 -nothing. But
encodeon that same text is 0.923.The reason is that
encodecalls normalisation once per stretch of ordinary text between addedtokens (
src/targets/qwen3_6/impl/frontend/tokenizer.cpp:652- the onlytokenizer.cppin thetree, and not under
src/text/, whereunicode.cpplives), not once per prompt. Most turns of aconversation are pure ASCII even when one of them carries Cyrillic, so most of those calls take
the fast path. A "is the whole
prompt ASCII" test understates where this helps.
The effect has no length threshold
Sweeping the distance between control tokens from 128 bytes to 256 KB,
encodeholds at0.843 to 0.859 across the whole range - a flat band, ten points, both passes. There is no
crossover to argue about and no constant to justify.
End to end, both regimes
Cold prompt, prefill on the card: no measurable effect, and that is the honest answer. 48 CLI
runs, all 48 points recorded
quiet:asciien_chat_32kchat_code_32kchat_en_32kchat_en_128kHost preparation is 0.9 to 1.3% of the run, and at three runs per cell the
preparecolumn doesnot resolve a 2 ms effect: the arm that must be faster reads slower on three of the four fixtures
(+2.4, +1.1, +2.0 ms) and faster on the fourth (-1.1 ms). Total time is flat - ratios 1.0000, 0.9987,
1.0000, 1.0000. The correct statement for cold prompts is "no measurable change", not a small
gain.
Hit prefix cache: the whole difference is visible. The instrument here is
apps/ninfer-serve,not
ninfer_bench, and the distinction is what makes the rows mean anything, so it is statedbefore the numbers.
ninfer-serveships with prefix reuse on -src/serve/serve_options.h:50,bool allow_prefix_reuse = true;, byte-identical onad0f3d38and487f8977- and turns it offonly for an explicit
--no-prefix-reuse(src/serve/serve_options.cpp:286). The command belowpasses no such flag, so both arms ran at the shipped default with the cache live; that is the
configuration these four cells describe, and it is the configuration a deployed server has.
ninfer_bench, by contrast, hard-codesallow_prefix_reuse = falseatbench/targets/qwen3_6_27b/ninfer_bench.cpp:65on both bases, which is why noninfer_benchnumber anywhere in this report is a prefix-hit number and none is offered as one.
That the cache was permitted is a code fact; that it was hit is a logged fact, and the log is
the evidence:
ninfer-serve --request-log-jsonl, 40 requests per arm, two passes, 16 sessions.Request 1 prefills 8 475 tokens (762.6 ms); requests 2..40 record
computed_prefill_tokens=0andprefix_cache_hit_tokens=8475for this fixture (the secondfixture in the same log reports 7952) - a complete hit, every token of the
prompt served from the cache - and prefill collapses to 1.6 ms.
asciien_chat_32kchat_en_32kTokenisation is 79 to 81% of TTFT there, and the change takes 4.9 to 14.8% off TTFT - four
cells, 0.852, 0.884, 0.901, 0.951. The two instruments agree on the mechanism: the isolated bench
puts
encodeat 0.858 on this fixture, the server's owntokenizerecord at 0.827..0.842.Scope of that claim, stated exactly. It is a statement about
ninfer-serveat its defaultallow_prefix_reuse = true, on requests the log shows were served entirely from the prefix cache.It is not a statement about
ninfer_benchat any setting, and it does not follow from anyninfer_benchrow in this report. These server rows were taken onad0f3d38and have not beenre-measured on
487f8977; what has been checked on both bases is only that the option they dependon reads the same, which it does.
So the honest headline is conditional. This is worth nothing on a cold long prompt, where prefill
dominates by two orders of magnitude, and it is worth 5 to 15% of TTFT on cached-prefix traffic -
agents and chat UIs that resend a growing conversation, which is exactly the traffic the prefix cache
exists for. Both numbers are above; neither is presented without the other.
Tradeoffs
shape a prompt can take. Measured, that shape is inside the noise band on this bench: 1.011 /
0.984 on
ascii_then_high_32k, and 0.947..1.025 across all nine non-ASCII fixtures.untouched; the numerical behaviour of the tokenizer is unchanged bit for bit.
malloc/freepair and one intermediate buffer disappear per call.
to fit and no artifact to fit it on. The predicate is a property of the input, not of the model.
Reproduction
The operator and
encoderows come from a standalone harness that compiles the tree's ownsrc/text/unicode.cpp,src/targets/qwen3_6/impl/frontend/tokenizer.cppand vendoredutf8procandcalls
uni::normalize_nfcandfi::Tokenizer::encodedirectly, with the tokenizer built from theproduct artifact's own vocabulary. The harness is research scaffolding and is not part of this PR.
Checks not run, and the resulting limitations
The share of pure-ASCII text in real production traffic was not measured. Every number above is
against fixtures whose composition is stated explicitly in the table; carrying them over to a
traffic mix without measuring that mix would not be sound. The per-segment finding says the
applicable share is larger than a whole-prompt test suggests, but it does not say how large.
The cold-prompt regime is a null result, not a small win. The CLI
preparecolumn at threeruns per cell cannot resolve 2 ms; it is reported as flat rather than fitted.
The performance numbers were taken on the measurement host,
cteston the test host. Noperformance figure in this report was taken on the test host and none is compared across the two.
One microarchitecture per figure, and two toolchains across editions. The rows in this
report are g++ 13.3.0 at
-O3on Zen 2 and Zen 4; the re-measurement quoted above ran underg++ 15.2.0 on a different host, and the two moved together, so this report does not separate
them. The scan is a
plain byte loop, so the shape should carry, but the absolute scan rate is these machines'.
The server rows are one artifact and one prompt shape per fixture, 40 requests per arm per
pass. The within-arm spread of the server's own
tokenizerecord is large (41 to 81% between minand max), which is why medians over 40 requests and two passes are reported rather than single
runs.
ninfer_qwen3_6_27b_prefix_real_testwas red ona140e7aebefore this branch existed, and isnot red any more. On
a140e7aeit failed identically on both arms and was reported rather thanfixed. Upstream
b8786751(fix(runtime): correct aliased state ownership) is the maintainer'sfix for exactly that failure: re-measured on one host with the same artifacts, the test fails on
bare
a140e7aein 49.31 s with the same message and counters (path=5 reused=267) andpasses on the newer base in 126.77 s, together with the other five weight-gated tests, 6 of 6.
cteston the base this branch targets, taken 2026-09-05 from one build directory, both arms.Base
ad0f3d38(treeaf48897f): 104 of 104 passed, 0 failed, six weight-gated tests skipped.This branch
ac6a69e3(treeced69cfe): 104 of 104 passed, 0 failed, the same six skipped.Same CMake configuration for both,
-DBUILD_TESTING=ON, CUDA 13.1; the arm was produced bychecking the branch out in the same worktree and rebuilding incrementally, so the two runs differ
in nothing but the commit. Both runs were taken under the host's GPU lock, one arm at a time.
🤖 Generated with Claude Code