@@ -183,7 +183,7 @@ impl LoadedModel {
183183}
184184
185185/// Decoding parameters for [`transcribe_samples`] (greedy, temperature 0).
186- #[ derive( Debug , Clone , Default ) ]
186+ #[ derive( Debug , Clone , Default , PartialEq , Eq , Hash ) ]
187187pub struct DecodeParams {
188188 /// Source language code (e.g. `"en"`). `None` triggers auto-detection on
189189 /// multilingual models; ignored by English-only models.
@@ -2990,6 +2990,95 @@ pub fn transcribe_samples(
29902990 } )
29912991}
29922992
2993+ /// Exact-equivalent batch jobs that can share one physical transcription.
2994+ struct BatchJobGroup {
2995+ representative : usize ,
2996+ members : Vec < usize > ,
2997+ }
2998+
2999+ /// Fast, non-cryptographic fingerprint over the exact IEEE-754 sample bits.
3000+ /// Hash matches are always verified bit-for-bit before work is shared, so a
3001+ /// collision can only cost an equality scan; it cannot alias two audio jobs.
3002+ fn batch_audio_fingerprint ( samples : & [ f32 ] ) -> u64 {
3003+ let mut hash = 0x9e37_79b9_7f4a_7c15_u64 ^ samples. len ( ) as u64 ;
3004+ for & sample in samples {
3005+ let word = u64:: from ( sample. to_bits ( ) ) . wrapping_mul ( 0xbf58_476d_1ce4_e5b9 ) ;
3006+ hash ^= word;
3007+ hash = hash. rotate_left ( 27 ) . wrapping_mul ( 0x94d0_49bb_1331_11eb ) ;
3008+ }
3009+ hash ^= hash >> 30 ;
3010+ hash = hash. wrapping_mul ( 0xbf58_476d_1ce4_e5b9 ) ;
3011+ hash ^= hash >> 27 ;
3012+ hash = hash. wrapping_mul ( 0x94d0_49bb_1331_11eb ) ;
3013+ hash ^ ( hash >> 31 )
3014+ }
3015+
3016+ fn batch_jobs_identical ( left : ( & [ f32 ] , & DecodeParams ) , right : ( & [ f32 ] , & DecodeParams ) ) -> bool {
3017+ if left. 1 != right. 1 || left. 0 . len ( ) != right. 0 . len ( ) {
3018+ return false ;
3019+ }
3020+ std:: ptr:: eq ( left. 0 . as_ptr ( ) , right. 0 . as_ptr ( ) )
3021+ || left
3022+ . 0
3023+ . iter ( )
3024+ . zip ( right. 0 )
3025+ . all ( |( & a, & b) | a. to_bits ( ) == b. to_bits ( ) )
3026+ }
3027+
3028+ fn coalesce_batch_jobs ( jobs : & [ ( & [ f32 ] , DecodeParams ) ] ) -> Vec < BatchJobGroup > {
3029+ use std:: collections:: HashMap ;
3030+ use std:: hash:: { Hash , Hasher } ;
3031+
3032+ let mut groups: Vec < BatchJobGroup > = Vec :: with_capacity ( jobs. len ( ) ) ;
3033+ let mut by_fingerprint: HashMap < u64 , Vec < usize > > = HashMap :: with_capacity ( jobs. len ( ) ) ;
3034+ for ( job_index, ( samples, params) ) in jobs. iter ( ) . enumerate ( ) {
3035+ let mut params_hash = std:: collections:: hash_map:: DefaultHasher :: new ( ) ;
3036+ params. hash ( & mut params_hash) ;
3037+ let fingerprint = batch_audio_fingerprint ( samples) ^ params_hash. finish ( ) . rotate_left ( 17 ) ;
3038+ let matching_group = by_fingerprint. get ( & fingerprint) . and_then ( |candidates| {
3039+ candidates. iter ( ) . copied ( ) . find ( |& group_index| {
3040+ let representative = groups[ group_index] . representative ;
3041+ batch_jobs_identical (
3042+ ( samples, params) ,
3043+ ( jobs[ representative] . 0 , & jobs[ representative] . 1 ) ,
3044+ )
3045+ } )
3046+ } ) ;
3047+ if let Some ( group_index) = matching_group {
3048+ groups[ group_index] . members . push ( job_index) ;
3049+ } else {
3050+ let group_index = groups. len ( ) ;
3051+ groups. push ( BatchJobGroup {
3052+ representative : job_index,
3053+ members : vec ! [ job_index] ,
3054+ } ) ;
3055+ by_fingerprint
3056+ . entry ( fingerprint)
3057+ . or_default ( )
3058+ . push ( group_index) ;
3059+ }
3060+ }
3061+ groups
3062+ }
3063+
3064+ /// Enable exact duplicate elimination in [`transcribe_samples_batch`].
3065+ ///
3066+ /// The default is on. `FW_BATCH_COALESCE=0` (also `false` or `off`) restores
3067+ /// one physical transcription per input for same-binary comparison and
3068+ /// operational rollback.
3069+ fn batch_coalesce_enabled ( ) -> bool {
3070+ use std:: sync:: OnceLock ;
3071+ static ON : OnceLock < bool > = OnceLock :: new ( ) ;
3072+ * ON . get_or_init ( || {
3073+ std:: env:: var ( "FW_BATCH_COALESCE" ) . map_or ( true , |value| {
3074+ !matches ! (
3075+ value. trim( ) . to_ascii_lowercase( ) . as_str( ) ,
3076+ "0" | "false" | "off"
3077+ )
3078+ } )
3079+ } )
3080+ }
3081+
29933082/// Transcribe independent audio inputs concurrently through one loaded model.
29943083///
29953084/// This is the multi-file counterpart to [`transcribe_samples`]. Model weights
@@ -2999,6 +3088,11 @@ pub fn transcribe_samples(
29993088/// caller otherwise faces: serializing every file, or loading one multi-GB
30003089/// model per process.
30013090///
3091+ /// Before scheduling, jobs with bit-identical samples and equal
3092+ /// [`DecodeParams`] are coalesced. Fingerprint matches are collision-checked,
3093+ /// so distinct jobs can never alias; successful results are cloned back into
3094+ /// their original positions. Set `FW_BATCH_COALESCE=0` to disable this path.
3095+ ///
30023096/// At most one file lane is admitted per four Rayon workers. Nested frontends
30033097/// and transformer kernels reuse that same fixed pool, so additional lanes do
30043098/// not create additional threads; they expose independent ready work whenever
@@ -3026,25 +3120,80 @@ pub fn transcribe_samples_batch(
30263120 }
30273121
30283122 super :: ensure_default_rayon_pool ( ) ;
3123+ let groups = if batch_coalesce_enabled ( ) {
3124+ coalesce_batch_jobs ( jobs)
3125+ } else {
3126+ ( 0 ..jobs. len ( ) )
3127+ . map ( |job_index| BatchJobGroup {
3128+ representative : job_index,
3129+ members : vec ! [ job_index] ,
3130+ } )
3131+ . collect ( )
3132+ } ;
3133+ super :: perf_span (
3134+ "batch_coalesce" ,
3135+ 0.0 ,
3136+ & format ! ( "\" jobs\" :{},\" unique\" :{}" , jobs. len( ) , groups. len( ) ) ,
3137+ ) ;
30293138 const MIN_THREADS_PER_FILE : usize = 4 ;
3030- let lanes = jobs
3139+ let lanes = groups
30313140 . len ( )
30323141 . min ( ( rayon:: current_num_threads ( ) / MIN_THREADS_PER_FILE ) . max ( 1 ) ) ;
3033- let next_job = std:: sync:: atomic:: AtomicUsize :: new ( 0 ) ;
3142+ let next_group = std:: sync:: atomic:: AtomicUsize :: new ( 0 ) ;
30343143
30353144 let mut completed: Vec < ( usize , FwResult < DecodeOutput > ) > = ( 0 ..lanes)
30363145 . into_par_iter ( )
30373146 . flat_map_iter ( |_| {
30383147 std:: iter:: from_fn ( || {
3039- let index = next_job. fetch_add ( 1 , std:: sync:: atomic:: Ordering :: Relaxed ) ;
3040- jobs. get ( index) . map ( |( samples, params) | {
3041- ( index, transcribe_samples ( m, samples, params, checkpoint) )
3148+ let group_index = next_group. fetch_add ( 1 , std:: sync:: atomic:: Ordering :: Relaxed ) ;
3149+ groups. get ( group_index) . map ( |group| {
3150+ let representative = group. representative ;
3151+ let ( samples, params) = & jobs[ representative] ;
3152+ (
3153+ group_index,
3154+ transcribe_samples ( m, samples, params, checkpoint) ,
3155+ )
30423156 } )
30433157 } )
30443158 } )
30453159 . collect ( ) ;
3046- completed. sort_unstable_by_key ( |( index, _) | * index) ;
3047- completed. into_iter ( ) . map ( |( _, result) | result) . collect ( )
3160+ completed. sort_unstable_by_key ( |( group_index, _) | * group_index) ;
3161+
3162+ let mut outputs: Vec < Option < FwResult < DecodeOutput > > > = ( 0 ..jobs. len ( ) ) . map ( |_| None ) . collect ( ) ;
3163+ for ( group_index, result) in completed {
3164+ let group = & groups[ group_index] ;
3165+ match result {
3166+ Ok ( output) => {
3167+ for ( position, & job_index) in group. members . iter ( ) . enumerate ( ) {
3168+ let mut logical_output = output. clone ( ) ;
3169+ if position > 0 {
3170+ // Work provenance is physical, not logical: cached
3171+ // followers perform no encoder or decoder operations.
3172+ logical_output. work = DecodeWorkStats :: default ( ) ;
3173+ }
3174+ outputs[ job_index] = Some ( Ok ( logical_output) ) ;
3175+ }
3176+ }
3177+ Err ( error) => {
3178+ let ( & first, followers) = group. members . split_first ( ) . expect ( "nonempty group" ) ;
3179+ outputs[ first] = Some ( Err ( error) ) ;
3180+ // Error values are intentionally not cloneable. Re-run only
3181+ // failed followers so each receives its own precise error.
3182+ for & job_index in followers {
3183+ outputs[ job_index] = Some ( transcribe_samples (
3184+ m,
3185+ jobs[ job_index] . 0 ,
3186+ & jobs[ job_index] . 1 ,
3187+ checkpoint,
3188+ ) ) ;
3189+ }
3190+ }
3191+ }
3192+ }
3193+ outputs
3194+ . into_iter ( )
3195+ . map ( |output| output. expect ( "every batch group resolves its members" ) )
3196+ . collect ( )
30483197}
30493198
30503199/// Compute DTW word timings for one window, returning per-segment word lists
@@ -3435,6 +3584,49 @@ mod tests {
34353584 // Synthetic tokenizer for hermetic logit-filter / segment tests.
34363585 // -----------------------------------------------------------------------
34373586
3587+ #[ test]
3588+ fn batch_coalescing_shares_only_exact_audio_and_params ( ) {
3589+ let audio = vec ! [ 0.0 , 0.25 , -0.5 , f32 :: from_bits( 0x7fc0_0001 ) ] ;
3590+ let audio_copy = audio. clone ( ) ;
3591+ let signed_zero_variant = vec ! [ -0.0 , 0.25 , -0.5 , f32 :: from_bits( 0x7fc0_0001 ) ] ;
3592+ let params = DecodeParams :: default ( ) ;
3593+ let mut translated = params. clone ( ) ;
3594+ translated. translate = true ;
3595+ let jobs = [
3596+ ( audio. as_slice ( ) , params. clone ( ) ) ,
3597+ ( audio_copy. as_slice ( ) , params. clone ( ) ) ,
3598+ ( signed_zero_variant. as_slice ( ) , params. clone ( ) ) ,
3599+ ( audio. as_slice ( ) , translated) ,
3600+ ] ;
3601+
3602+ let groups = coalesce_batch_jobs ( & jobs) ;
3603+
3604+ assert_eq ! ( groups. len( ) , 3 ) ;
3605+ assert_eq ! ( groups[ 0 ] . representative, 0 ) ;
3606+ assert_eq ! ( groups[ 0 ] . members, vec![ 0 , 1 ] ) ;
3607+ assert_eq ! ( groups[ 1 ] . members, vec![ 2 ] ) ;
3608+ assert_eq ! ( groups[ 2 ] . members, vec![ 3 ] ) ;
3609+ }
3610+
3611+ #[ test]
3612+ fn batch_coalescing_preserves_first_seen_group_order ( ) {
3613+ let first = vec ! [ 1.0 , 2.0 ] ;
3614+ let second = vec ! [ 3.0 , 4.0 ] ;
3615+ let first_copy = first. clone ( ) ;
3616+ let params = DecodeParams :: default ( ) ;
3617+ let jobs = [
3618+ ( first. as_slice ( ) , params. clone ( ) ) ,
3619+ ( second. as_slice ( ) , params. clone ( ) ) ,
3620+ ( first_copy. as_slice ( ) , params) ,
3621+ ] ;
3622+
3623+ let groups = coalesce_batch_jobs ( & jobs) ;
3624+
3625+ assert_eq ! ( groups. len( ) , 2 ) ;
3626+ assert_eq ! ( groups[ 0 ] . members, vec![ 0 , 2 ] ) ;
3627+ assert_eq ! ( groups[ 1 ] . members, vec![ 1 ] ) ;
3628+ }
3629+
34383630 fn hp ( n_vocab : i32 ) -> WhisperHParams {
34393631 WhisperHParams {
34403632 n_vocab,
0 commit comments