Skip to content

perf(asr): bulk-fill MLMultiArray resets and copies - #941

Open
vakharwalad23 wants to merge 7 commits into
FluidInference:mainfrom
vakharwalad23:perf/mlmultiarray-reset-memset
Open

vakharwalad23 wants to merge 7 commits into
FluidInference:mainfrom
vakharwalad23:perf/mlmultiarray-reset-memset

Conversation

@vakharwalad23

@vakharwalad23 vakharwalad23 commented Sep 20, 2026

Copy link
Copy Markdown

Why is this change needed?

MLArrayCache.returnArray clears every returned array with MLMultiArray.resetData(to: 0), which stores one NSNumber per element through the subscript. The preprocessor input is [1, 240000] float32, so every AsrManager.transcribe call spends about 20 ms zero-filling it (release build, M3 Pro) on the path that returns the transcript, after the models are already done. TdtDecoderState(from:) copies the LSTM state the same way, one element at a time, and since #910 it runs before every recoverable inference.

This PR fills and copies through the backing storage instead:

  • resetData(to: 0) is one memset over the array's byte extent for every data type.
  • Non-zero values fill through a typed pointer for float32, float64 and int32; other types keep the element loop.
  • copyData(from:) is one memcpy when shape, data type and strides match; otherwise it keeps the element loop.

withUnsafeMutableBytes reports the full backing extent, so the ANE-aligned arrays with padded strides ([10, 10] with strides [16, 1]) are covered too.

Measurements

Apple M3 Pro, macOS 26.6, Xcode 26.6.

Unit level, debug test build:

Operation Before After
resetData(to: 0), [1, 240000] float32 42.9 ms 0.005 ms
copyData(from:), [1, 240000] float32 62.9 ms 0.012 ms

Release build, the same 240000-element reset in isolation: 21.5 ms as an NSNumber loop, 0.006 ms as memset.

End to end, release build, AsrManager.transcribe on an 11 s clip with a Parakeet TDT 0.6B v3 bundle (Orukeet), median of 20 warm runs, main vs this branch:

Before After
transcribe wall time 71.1 ms 48.3 ms

Transcripts are identical before and after.

Tests

  • MLArrayCacheTests.testReturnArrayResetsPreprocessorBufferWithinBudget (fails on main at 42.9 ms against a 5 ms budget, best of 5)
  • MLArrayCacheTests.testReturnArrayResetsPaddedStrideArray
  • TdtDecoderStateV3Tests.testMLMultiArrayCopyDataLargeArrayWithinBudget (fails on main at 62.9 ms)
  • TdtDecoderStateV3Tests.testMLMultiArrayCopyDataAcrossStrideLayouts
  • TdtDecoderStateV3Tests.testMLMultiArrayResetDataInt32Value, testMLMultiArrayResetDataFloat16Value

The full suite passed locally with model downloads enabled (2534 tests).

MLArrayCache.returnArray reset the 240000-sample preprocessor input one
NSNumber at a time, about 20 ms per transcription on the path that
returns the transcript, and TdtDecoderState(from:) copied the LSTM state
the same way before every recoverable decode. Reset through memset over
the backing extent, fill other values through a typed pointer, and
memcpy between identical layouts; the element loops remain as fallbacks.
Copilot AI lite review requested due to automatic review settings September 20, 2026 08:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Replace the overlapping-region memcpy path with safe overlap handling, and strengthen padded-byte coverage.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity

Open (1)
What changed in this PR

Optimizes MLMultiArray reset and copy operations for ASR performance.

Changes:

  • Adds bulk zeroing, typed fills, and compatible bulk copies.
  • Preserves stride-aware fallback behavior.
  • Adds correctness and performance regression tests.
File Description
Tests/​FluidAudioTests/​Shared/​MLArrayCacheTests.swift Tests cache resets and padded strides; padding-byte coverage remains a nit.
Tests/​FluidAudioTests/​ASR/​Parakeet/​SlidingWindow/​TDT/​Decoder/​TdtDecoderStateV3Tests.swift Tests reset, copy, stride, and performance behavior.
Sources/​FluidAudio/​ASR/​Parakeet/​SlidingWindow/​TDT/​Decoder/​TdtDecoderState.swift Implements optimized reset/copy paths; the memcpy fast path must handle overlapping regions safely.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread Sources/FluidAudio/ASR/Parakeet/SlidingWindow/TDT/Decoder/TdtDecoderState.swift Outdated
memcpy is undefined when source and destination overlap, which zero-copy
views of one allocation and a self-copy can produce. memmove keeps the
bulk path correct there, a self-copy returns early, and the tests cover
overlapping views, self-copy, and the padding bytes of an aligned array.
@Alex-Wengg

Copy link
Copy Markdown
Member

Thanks for the speedup. One correctness concern that should be addressed before merge, two test fixes that go with it, and a few optional cleanups.

Blocking

  • resetData / copyData write the full strided byte span, not the logical count (TdtDecoderState.swift:107, :145). withUnsafeMutableBytes reports strides[0] * shape[0] * elemSize, which includes stride gaps and trailing padding. Every current caller (createAlignedArray, MLArrayCache.getArray, CoreML h_out/c_out) allocates with the padded formula so this is in bounds today, but ANEMemoryOptimizer.createZeroCopyView (Shared/ANEMemoryOptimizer.swift:92-96) bounds-checks by logical element count while assigning padded strides. Any view from that path with an innermost dim not divisible by 16 becomes a heap overrun if it reaches resetData, copyData, or returnArray. Reproduced with a [10,10] float32 view, strides [16,1], over a 400-byte buffer: memset writes 192 bytes past the allocation.

    Suggested fix: take the fast path only when span == count * elemSize, or clamp the write to the last logical element's byte offset.

Tests to fix alongside

  • testReturnArrayResetsPaddedStrideArray can't catch the regression it guards (MLArrayCacheTests.swift:247). It dirties only logical elements via the subscript, and createAlignedArray already zero-clears the allocation, so the padding is never non-zero. Reverting resetData to the old per-element loop still passes. Poison the full backing first, e.g. array.withUnsafeMutableBytes { b, _ in b.bindMemory(to: Float.self).update(repeating: .nan) }, then assert zeros.
  • Hard 5 ms best-of-5 budgets in testReturnArrayResetsPreprocessorBufferWithinBudget (MLArrayCacheTests.swift:225) and TdtDecoderStateV3Tests.swift:257. These run unfiltered in the parallel debug CI job on macos-15 with --num-workers $(sysctl -n hw.ncpu), and the timed region includes an actor hop. The file's own note at L205 says a performance test was removed for timing issues. Suggest a threshold-free measure {} block like testArrayResetPerformance, or gate behind the existing CI env check at L25.

Non-blocking

  • copyData's per-element fallback (:152) isn't overlap-safe, though the second commit and the overlapping-views test establish that as the contract. Unreachable today since init(from:) always uses a fresh contiguous destination. A doc comment stating the fallback assumes disjoint storage would be enough.
  • if value == 0 uses NSNumber equality, so -0.0 takes the memset branch and lands as +0.0. All callers pass literal 0. Worth a one-line doc note.
  • The zero-on-return reset being sped up looks like dead work: the only getArray consumer (AsrManager.swift:182) memcpy's the full extent immediately, and short-window zero-padding happens in padAudioIfNeeded beforehand. The new tests now entrench that invariant. Consider dropping the reset instead.
  • This leaves three divergent fill helpers in the target: Shared/MLMultiArray+Extensions.swift reset(to:) (logical count only, silently no-ops non-float32/int32, 14 callers), ModelWarmup.resetToZeros(), and this one, which Shared/MLArrayCache.swift already reaches into ASR/.../Decoder/ for. Natural moment to move resetData/copyData into Shared and have reset(to:) delegate.
  • copyData carries checks that can't fire (self === source, destination.count == origin.count, both baseAddress unwraps) and threads a Bool through nested closures. Hoisting the layout guard and using destination.copyMemory(from:) gets it to roughly 10 lines. Keep resetData's baseAddress guard since memset(NULL, 0, 0) is UB.
  • testMLMultiArrayCopyDataFromItselfLeavesValues samples 14 of 1280 elements with by: 97. createTestArray(multiplier:) plus verifyArraysEqual gives a full check. Same for the manual loops in testMLMultiArrayResetDataFloat16Value and testMLMultiArrayCopyDataAcrossStrideLayouts, which duplicate verifyArrayHasValue / verifyArraysEqual.
  • No test covers the .float64 arm or a non-zero .float16 fill.

withUnsafeMutableBytes reports the padded byte span, so a view built with
padded strides over a tighter allocation would be overrun by a span-wide
memset or copy. The bulk paths now require the span to equal count times
the element size; padded layouts keep the element loop, so nothing past
the last element is written. The copy goes through copyMemory, which is
overlap-safe, and the doc comments state the overlap and -0.0 behavior.
resetData and copyData move next to reset(to:) in Shared, and reset(to:)
delegates to them, so MLArrayCache no longer reaches into the decoder for
its helper. reset(to:) used to walk count contiguous slots, which skipped
the last rows of a padded array; the stride-aware fill covers them. The
warm-up's private vDSP fill goes the same way.
The only getArray consumer overwrites the full extent of the preprocessor
input with memcpy, and every caller pads the audio before that, so the
reset on return was dead work: 240000 boxed stores, about 20 ms, on the
path that returns the transcript.
createZeroCopyView checked the logical element count against the source
but built the view with padded strides, so a view whose innermost
dimension is not a multiple of 16 could extend past the source storage.
The check now uses the span the strides imply, as ANEMemoryUtils does.
@vakharwalad23

Copy link
Copy Markdown
Author

Working through these now on the same branch: the fast path will apply only to contiguous storage (span == count * element size) with the element loop kept for padded strides, the padded-stride test poisons the full backing before asserting, the budget tests are gated off in CI, and the non-blocking items (helpers consolidated under Shared with reset(to:) delegating, copyMemory-based copy, overlap and -0.0 notes, full-array test checks, a float64 case) go in alongside. I will also drop the zero-on-return reset as suggested, since the only getArray consumer overwrites the full extent. Push coming shortly.

@vakharwalad23

Copy link
Copy Markdown
Author

Pushed five commits covering the review. The bulk paths now run only on contiguous storage (span == count * element size), with the element loop kept for padded strides and sentinel tests for the overrun; createZeroCopyView bounds by its padded span, which was the root cause. The padded test poisons the backing first, the timing budgets skip under CI, the reset on return is gone, resetData/copyData live in Shared with reset(to:) delegating (that also fixed its padded-array gap), copyData is the copyMemory shape, the overlap and -0.0 notes are in, and the tests use the file helpers with a float64 case added. Full suite green locally, download-gated classes included.

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.

3 participants