From 2b5a3dd6b347e6609d197b4a686c790bf5429203 Mon Sep 17 00:00:00 2001 From: WeiQing Chen <40507679+david6666666@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:19:02 +0800 Subject: [PATCH 01/38] [skip ci] [Docs] Update WeChat QR code for community support (#1802) Signed-off-by: david6666666 Co-authored-by: david6666666 --- docs/assets/WeChat.jpg | Bin 127996 -> 100112 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/assets/WeChat.jpg b/docs/assets/WeChat.jpg index c5e88d616e20e18637628543b4140f8f5ab6c788..ce90f1753d617a94e2b0c20dcc06ae51c10ebe05 100644 GIT binary patch literal 100112 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%0DN2l%Z*ueUN&I&6{;9XNPsg&O_%eej9_CRJsZ= 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

uke^}+F>tx>`Chi4Lee%$epAW{3 zGfx_40z1|Ye49}fsFnx~?h@T%m8X(x7WLE5r?=_*=v_Kq zQ7%U-p?1LHA92 zn5K?pV*AP{*?WhfV1XY#>?=fg5G7HEDb+FKk$_`4*9Eg1Jmg=)vTAzTySdg=hwK3? z8q+5y9WPl-Ix)Zj)FD0+iN)a$bPwo>5jzafhiRH{7X4Ix7bY|9s$5<_prK;ljg0$@ zQnnYP>nFKJ^|2i@jSol4}g#7H{b=>7rA!>IvLZ`w9uk zs5l-Wq2XdxI^D36Gm}vRGpbZ7xo)*}45RO4fCxEfN9l^T+m7OM#z9#$! zAsv4{q|OGu6qocm2LpHr&S0Rm9{bYC<=@;=TT5eeGdCFMaBg&X_@fl!z&oFuZ!#Qt z_Fg7RvF~m|Lh~ytu_9@>(n01$-8%6g#|2m3@9mW! z+q8`sf00Nic=k*cqk2x30PO*)A(T$b&wgyWgl?vgq@{I|j*iZ+`1F3Hmot05jgBrU zVH~XRKFpa}>)p9|tz59v7xDhxc z@Wl)9Q$6s_L##gTd;08IN^=wH1#{1#!^n!9EXxCZdMqDLaM+QwoScb0(g3`=F5P%e z%_okQ;QRM?7G*#P($9Q`u`P>uOdXZAFj$qod-tyCeE3}naFO)THslDm51@P{cDLdB z^0#*p(Q*wex4u!rC~h|h3=A~!SJ)g%yGv@?|3CPq;o^t?12}U3zjyI#tM!+c6omO~ zP>eYRT7p`xK!(=#n1ULAAkG0@N<~HGQ7c?xZv;Fh;_qX?&E@C!q*kPMJpraMtHuN! zhv0U4Ske;m03Ev|>F)!D>4pu7V4efx`_3ch%bc06e||2P)>e2`*AdRxLh}JWzS|(1 zT43Db#P~EB;wyR*Yin!$lanV`%jCsvhbX07euh|dL51;NpK^nIMFd^>v0TQF5t@y( z+6Ec7HYkmiZ`J=SJc6|R8qcudYD8N{&?MfX3rZN$7MT77Ud9uMZt$}r z3o{Bx?K_98&US1JwF4p|V4p0R_j0LRf2HG-ykERnYpcd{E-LYfnRW(+<{RMGx37i#|aqNHZIBfLzG~vysqIEH{AugqYnk62e zn!0ZmSXXC)NKLWGxGtT{V&^OWv*u#t^a5XBCPBm_0VY#A$NDP%?Ah!?>kXn1C35|9 zj6|I*p50RBy#cwOC?KM7F(+qd{iR431T+DdQ$yPjmutLpdEZgX&c{jD_mINVPTU{w zc;Pt1iK4c(NFV#T0YAd+!7$coqbGP+QeHEbQXtY)Ma`SpT#xTV zS1P3nKdlSdxE{WxeIY7Conal2G%-;nA%Ud#PLX}HMBoF3E2jE464P8n06^N;-+yA; z!mpL(n{vnb5XEl4$knaqo0-XLVr*RX$_+YUdy6<+_%Up-_29|9S1Ly^!PHA^O*4eU zB_u%flV$2FWVS;y!alw`PB_@G0OvXpe`Uu$XqzbnLL3+yYcM1LzQmgY*z!Kav$9Gw z5Y;7cY>gV*WkQs7==pc3G_)Czw_b9p^o?N-o>s9Qx#=!OBa%CA9|pe?b?C7k51#x4 zI|5(y^C$PKTa&Fbg~SNJ_;{nCCG1y;H_w6^;OzteFlWkzSPyb2(p5Hjf102aH+KAoRn|RDwbYj-?m`U;YnC1l2Zx+Z&yb**=wy6y zfo>PaE8mst4LOeY%S_otdN28ZPY^Qo>v#XLkn;%W;eSFoHgQH3du*3yFI7nh(0nlA z7^)^CHE!=(1xuj{B^0rkLMz+}ubV3iuX)AA&)OJ;?v*yE(?zSGs2f}8`%RA^=>y7H zy<7D^5C@?QCB`g5?}cwYN;-xCGJ89$Z;HBC$r)Da5s(TgLm3Wi`)MLUCq^&mbe1bC3wWeY3gz%xC4?Nwqe#^2X`>`U)_DQ~p%eZte>cw)P57@o6mW&8&H ziOq`_j8%j*HXV@Pw|sNv)Uol&h?lZMf0U_NIleI%Hr9S@A2_)1Q?DkG<>v*TgF5f0 zve|1^jxMYfNN_Ama*$fp#rS^2VA{PfY?5rCZ4S2 z4T7u0`-3GpbUfx&gZiVTm)ZRtO=a>G-)=p~Y`q4&oyA_{8u@^Y^!@P3EoVP(kz4Yf zxkqFBoZfr6dPnI8lXHOV$!tLEPb)-Nfw&cdRLZ1v7X4TL)F+YExS9krvWYV>WR)n7 zuhCvLZCvK68H~zWyx&25R)mR{>Ww;AR^8wdzW+zpdw_G<_VL5NN@b)(LrO-3Qg+$0 zDw2^=Mj^?{$Osw9iUxN^D6?b}Wmc3Cg+yjivS%b@B)y;0{XEbA|GvlZ9>;TZ+)wfw z*L7a!`Tc&@chAb(zG+FgT)oUFWDcKC<@wP+<}M1`TosbNDdC(*x%l^^txdRm=peIw z^1dK<&iyN&&FkCpeq(eKK{R?fk$zJ5AnrZta82x>8@F!RagODbmw#0`%7RAD=W-^a zYH56QY}3~&`<;r4j*6~MCFLMcx2SjEC})}x2Q`LP28RpA}82ULK`fAlUzsHHr>VoS`^l#d&}Q@dsky) z?agj}s7yyqOWRu-pG4p}w3;T0xJBWO=ryJXsmC4Q8K$z+RjrANp7jK&=oD6`@X7o&wZE-UHUOrk<%qL z{v}So;rHzQsHY-x&k~#l8%mv0yRwZF3^Sod)-1fFKjFyl353OcRGJQ-b`=j#>fE)? z&Q3*i;UzA$Q39K=BQ?+SyT(~4H1V&E$@(xK{5)S{r9tCs|DT72<6OX(nv zdgE8sL}e+V0j#GAG!xBivWEAAFKWD?2iD_g{Igs*^YtB!E-NzyuH2aB@v)j4js*46Vx8nD0GCzpfvhc8@a#W zswApfC@o_2QD>fGW@HRI#%jz2)y@`6Vebfr5>+s-Ub(R=bt0xet-Q@fj zhrDLG3})>&sA@d3_VM0?(^+Wfco>%v!UOc;L@XLiO0UjF7oa%yM_IX*r4!#I&vW5t zeI7ltAf>Xha(ejX)mhpb8+KIeGKBHi0SAkL3_dJP9N0?|C~ylMgu*l-LURg7K)y6) zIN{W~1iAJza&}H(DH{~v3ck@5xQ5G{Ey<%E`x;!Qkj=vH-)0UDxnCVKw}A)p!_BF8 zzYkGhY2uIklSp}$ zbRinQtZ4}GShx;xpMoxBQ2K0{r+#V!JXtYg=HAb&B6+oZKQ-5UHIJ6NChO#NU(nQd zeAiDw&v}Qz4!x{1N!mk5b*s;Va%l(GA?+Kfw&%SPl=(>JfkYtykeyXPf!LfLR4mHg zSK%0soHK&_xfi=+bobj2`lxXthTbosw-Ci4^YJbh4Zc@7+)_W<*ijZ|8#`r6v7PoSQ^1IhMKn-x?BDJdz|Zcr?|0r=!# zGZ3H6w|_q!dWgDgkqd7b)J~iTKRZV&0;5;gLJnA`_^asx=xnF3UH1NJ!9gwULgZUE zDJikaomt|GoDvc{Y%g6Zma(#V0H@!(>}Rm8O529>A%o#20hSuHH8mM7T3Ebl2f$oG zlIsjpQ8K%anxhegTb|}gd$@J0X@Ys*~YJAHA-&> z1$}FS(sgUwp%~(0F?!}QV*McE_|>bD8+RVMEmep`X%l;RBpephC9Lie87zsDk8Fiq zHXNds02$EU;vjL0OQjf2H2MP|#_U)rM{w}c6M#p*Fja1OUQzKx$VQ3@_8L*=e$gKi z@v^b&gkT)GatY^7>koYW)1M6R){`zdMkSn5L`TD``Dg!a$kgf@<}4Dt_Er&jKVZNm)s3CAu`z6?A!GplRToDAu)VD60~2Op`n_*Vh)AYAKeD%{9dE0Z; zq`cju*AM=~yiKhCL5T6}!<$kZs!AhMykFP^QnrPDO!C~h&Fd=-$GlL1=K}+2HU$F9 z(k~n;ShTaA^4y@?MWfZbQETzK3f^_~KxghSxNOEy`n>YsUD~+mhf+xS%#($i)wbm@7rz>ST5Aj@gis#Zkq3)F1EI)B&t_tB(lt>x& zZ)`ffR_9?z+0U~K*d_39(U2Psb zjRrD=UVmR-A6)% zYZynJwQ=&x3!awnkN+^xOVyu6ToD?4NRSvdbE$k>u!l|_v7bj$^&YE6Mmtj|(4A*# zTL9czIl1EyV)MG)_*%9u51fK1X74`P$O((=8YugBWuMfsQ~vvjJs5 z5@acAk~n4pu?e0{O$EuS2tU7eJbcs?eAVWRXW-zYC0+J~6L`)P2d|{e=7s6`DYIcv zwAf>Y3J9JKG%XI+tr}QVJ@@c2M25?s}O0>IBcGM;J!O{;DdGqcE>6K1jK_XB=#!;PL_Xb^8 z52AM{|6KC~A`&2=*7I8RzgmE@d4=LsbO+^N7-D{wn%8ZEx#O;bhHAw(KZgI(^R$Q8 zdGP!%#(%ZXI*$KT7xK)^bY?r*t4M4}2RZ|a<+Y>2>s7*Fy5EYH;Ua(L)A^|yHo3)d zuZ>|`<$w#ddn5b4+sBuu-bx99`*av|Jg(#_W|_xwh1eg z^^#*66W0LcZ$OHX!XLPv%CRr6qWsM6Cb33vrpFlo55G=Ch;Q0)pu|lEZVu#@lG+JA zHequ4!-6Y_-3pmERaLK1*{)*$yZjCYzp~#GQgH@&Mgwwlb3Oa;)Hgk<`R%&Lk%^J< z=Q697?SR};kxu$4L8uPm;!%36g3w&;PcupVL8?Gz1FDT4VD4&vv?KTFlJp%(nUP!I zAu6#lp@-DgL)f<5git*-fO4=5ToisT<1 z=x8XAcwdn81~#<`ymq^bzX4p^u*{zFO){t-s=hg zVH9Z5K*6yQve6RyqP)CvG(%RcpFuv|+oyX|q7db~DVU4haj3(`L3Te20nG*GT`w~g zb+qIA<<=s5W`7f!rE-AyB4gr`lD%+S_*ZY+wd*#nvdORhk&$4Oscjis85n$&Z*DPd zV?ls*%Hzj0x9+Ut*sBV`1)lJ`na!&xGe3U3hD?CD)4NP9UOL8Rv^JJ|PUi{HMSHi)+0n80t1E~whC%YE58MwEvLG>U{_X^`FJi-%9V_XQ5!e96q^Yy> zfng?S^LZflrc>B>kgRlh@-Ui|q&59u$s9Z!ot>)?g-0I;(9J}r%9X*kMWlHO?cYCE+JGkJN4SP;&&rj<|DpY4Pi{LJ zBRdV~sLDad&`#yUy95t0XHhCp;y!5by2pTwo8g4Pi%0NYNBzUdXb9A#(C*9qPHmL+ z(@U{cAR|iFRZE-zL>N#Nc-)U6oEr%fFCGr?qjD1~{NtH^uJn0U2M#(hXIw}QDdH6o zJT*hb>3Ej4zBK3|+NP&V!;7qYyx0#P!-r{VjDZpRQZ}SFhG#sLmfuQntec;^nZeuN z_81I-*X1YXl?TlCQ#=C0 z4puc|>oxa4oYhB1c*=Yk&+1+ez+N(lel-{}j(%pFc*&!x> zdA_$(8SB(!WGfta^R7KyJjw7gQ9da6)IWjuS#*2_`BkO!KJujOW-}F!=#U$E=DlA? z_OarS)Mx;f4eb{i4(iz>tQ_*WNma`#2{lg``?MvU%O0WAI{&cox-yEPkkh`J0?tb; z+iPE4O~MN+oYtdJkcnQLvL2n<9I!K^yE@?KlYjrcNEj+BwLhxy={3igH*){dAT?6b zH0!K_9|KcB{9Ug9yqD?dpYZe=p4F1wS^}cfLR!PGIq{R4{qni;Yp!q}Zt>38&f*`< z+oH+j-*eZaE9T$#3fwbClgg!XHPhI6>1k5FTjb!eZsr%>xBT%%ZX)tgEkQQ!MJ%K< z8(2h-`|SB6jGPlBs((8;2YsjI(pi|7u_>-EQpZvu+*a2B^&cT*vLmwg+rc6LMXT(V z2S}VcvV$fZIb{ZNrum_%cB#6=Rsx3f3`3jrYI@n<2 z-Be${Jei-R5I`%45PiEite+#`1B&bNNci_K-r7}yWSYTxO*uvZ$4Nrb9HV|i^pprV zX7X<#a7s949me2uBq6CKb$Q=p7VEz3U>tau%2PNHoL_QKm;b?DLq8^2o~W zM*egF<#j%O$rQ^GJ1&tTA%EQL=E*RwC7|v3qtf0>%zys;iE)v1ow$u%U(_Mr{}6p{|2II9)0iL-j-%S7+PUm7d51=j5Cb#;IzU;t{Z=cQVqY9rArbn&w%J0JYVSn;?6HW;|lr zx$_nY#s8&~cV3yqz3FM-oUCHxRuvrbXnLp|4OHS755SfiKw`7{hK6ncf7{p5@a_hZ zWX@v;jGmqWuuFE0fUApeV&!2S)Vs6DdWs)aQtEF~QB|#gPEn+t8*UM4uf_bv4&()1 z#^tMD$kBSV?p%qZa%LMKfhM@FBs5Uhz)_%Pnx_&pBN)fWgpdI2a~D6iAvXX8(*BOS z-iScC@U;-wrHPeQy2}@kWk@-UP)Wrz4qE#CQmOx$gJ+*p0ZvfsY z84iwBB;F*MM9$$9xbTZN3dlkwsmKZs=w&hE=}{b7#73?F2rXO>1yeclO|1+i8rlC> zl=&%4`u|rzX{yP8+cuH?M^}|rZx)43F*t9ej%o7!;ezi{zVzH}m$j>zYXrU@=9Ei2 zhdp6_X5^SvUW}w_ZISh)zml@@b*Ly{+#7fS`JEyOdp#h+#KyLX9M2)PL}nTvf3QUx z5knAmi+D(aM!sNfKF~yBl2!^<{H{E}w{L(B6wLN)I03Mj;Aac-wiukVV(l%=K&!OT zsSFs0v+U_V6JMlAdaaWb8Ijau%ao+fu$kOJBZ0FB0GV+g8RHR2N#b5Tm?R6g%kK#_CxXzhQ}= zAyB#oL0fUR=OiJOn8z;ZFh*S1%m4?t4pro0LG*y^D;_%nMzNsbfb*FJHb0 ziHo069Kt2)K#ygYja5Tu^MHER5t_}3=l$Qm-@!Lwk%X8AeW{^cHYyz21imMUsCIi$ z*A$zLVGqoe6#-IRb7yDGcZ?xYVcxzyHOXae;;bDjIvHtOrt)f(pH0op;Zj4`Oqd2$ zBK;VE%1m$#I%la`jCbTcJrCHBSQDzW@ej;BRMpkJpAdX`kZ;g&tUg*u@%SZwQ<4^# z1sSh6ig2^e)7*~JXy(q+#{qb&vs=H6w4Xi78oXOeW}hP3ltmKAZNyoDNN?et%*?z} z8gNl7NCU)GxyMF%4GoHorM`hd?MKLC?*G%F#tO&6r<$9*It$|4YF~8B#f+%xobHEO_cgp0g=W1{MI)l^bvG*fD;uW9 zDJx3Np|k3JkUYa^K!~v~`^?$`o)o`*>+zR#Y|g(U*<0vXU-_3ME+-`Zs&dvGVE5n0 zPLRj3mItKgdJ8i0%m=6n^OJoP}~H9gLA?AS2~oBG-qKONdU zs;n+&hkp**izNZo>jwNvaRJWF*Iiw)FJd|PP9+Cr$u?tS1zi1CA-s<>`51@DOjlk&6b6B37+D!gx<6@->|VDZxedIx4S{s6Jx;FuAv zf&MH2+tc<@E2y{5Bp=^=Q3KW<<{dj?j`0B1mywadp|+QZYhpz#LK-`$D~W@R-?&WL z=`7^xHE8phI}5D|a7jpuAy5XF+H>OcvH^@H!Bspxg$`dI;^~Li_v~$X4YgJ->fid= zk1#agR4uSR87sc|`}glL1>nU(<>lqqg`C+NBL_@V|t zl46JvvJLRWS=eb%D24^r+@goHQ^9r+;39T%pKgYUs_IQaImU-qCv6LD+CxU#vba=; zN@$JvM>c%*0HPM}{?4W1i#AFDh@-y;=_Mcv6m}Gnr-Lqy$vEjJ9fBfP9|>j_*`Y#v1ov*gE66YvmgA$2cJ~;qmIdi z`6)%Zz_MvIl4v=*h+xeW5B!21j|wAl2X>Me4;L@khEY^hUidH&5d^C^rk)MHh~rYp zb)UYNPZZ3GyyIdAI!^!hWEVS+!X{j4ST%Y6|ir zHmuvg%-+=uFopvwg2HZW3`JQZMy&P3tHnTH@*1TWh5fH*d%E^|L>cmRH3Qx0f%;2r z_9=vKjn6DJvdO$PL4@0Sx;QSMedS%Mu7M5J4>Zf_Bg^iiokap5$WCRdgWImZFLp~M z^-SJ*_$DfRlf!}&6BFk?hz~_g0?eyGB^u!Ikq3w78lR9;oA$#&CyYt(ddtuZLz|GlA`S~II#*Otby$rY9mvGukZu|fl zrgnpN>ptpGqHMyk*wC_(_?+g)?UCpB17Ok`iec%}na*SZxN1${%2J#C2+A8i&J=AM zNThxMhDw=-NPaC)pl!Rm0n%XR%00erlss(`;a{LrF$HR+CP~jC>h>x@`6=2sah$in ze9Ak&L-ba_A)5dHLCR-e%JI>bn9T`49+p!%}}j;Ke1#*e^M5FT>tR^9I_YCGi+ zHV;FL@EbCthzRsDU~RcG_ubKZ1xQ03IX08b>-Z}DU>IPegtE%hJ-zgy1ZI4C*y-)eZ~@ z$d&|S_y#`~`oMAuz}HLIU3SMIV(Sv7P(2H|bB8`;hse<<3-90KIL-wKSue5g+)Hs} zZG|exjD(|W79^Vn4l}@>pd&87=N#l}{!w^jWB^u>c^gFhl~`1C@3mk(U0hfY z8iK`t6`o>`Yy*hi*4pb(@?!gUT!^cRzz#$780l~ zf#GoqLGqddDlvlsU`nAtA}!i7K%snnbm*$kENj$2XdN3hKtd1%z}ka;WOvGmxeNvx-=# zI@nMM)Nbq7w6*CCjo$(K02j=$i`gJ>`=Cyq288Lf9*;#`EDtb4BQ#SryTx{|+v$)0 zTvb|H8sl1hV|@#VwE(xz&nz|(P^L2+`MZb@J{QEJJ&2zS_w`lA*`snNq&^P_cD=UltggBxz~>aL}+); zlemTa2r@Q9L3$({7)}y|iLRc6*lmP$k-a@XR^kLid_TZ|eQd;2zPTs_5IjIz&Ep|3 z*yKWd)fInp+ku<7S51w*aShTI#J9bdi;FtgzA;hWs5xecAGPw9-u0VH)+I^Y!s|nj zsT-Y&?as;LDX=J5bF7E?&*W0@xk`viA`nNb!ug$2SNM^5d3hzw;i9>DDPfAXwXb~| z`{v6R*29-N*trVfLwJPvq9PuUOlanc1Mh)REDCx2m>YVAC>FAQm2t`J4nSl25|F9~ z8I_p@zeZ6=p;bSSs`p})cl!8R*D-j+>j~h*3YRHa3qGxfR0s8+DjlJ36ab*gg^65n zG1Z&>sRzp-<2`o8Ew8OL$(pgK^!uq|N`5%kk!k}6>BzJpQI!;h&Q z+Xc3P_3Xj>tnON>LtS1{2l3!*^f_X%ZjA4M4&TuR(K6d%S;BAsfDIc-ExiZlpmnrS z)BTO*Iu{x}MR>Teu0T?R)LB2kT8>Y(4bvVH5f=Wb_GT9hrI{Z*5t_lp#r5(p@T@}~uU;!y7E1m1M@Z)q*q=I}e z$Gnz7neuO;D=({;m%Di1hnBr#O-L_@DiQj%Ec$DXY4J_OuTZj8DjLaUcXj`d04 zW5KfvoI$>cRlmXn`CHE2-(KRFj)K0U^ea+|U9L`81&RS&EG zL*yj|Hs(e-YJ@zl35{ORttBC`;}`iR91oqbonh z%i9Z_JP;phnk#=?UK2cGZ}20&J;L49ImVnjbxI8IZ}0MQbXyN6Mh>eOM>}3xRI-b2 z(R@Yc`>{`?U|A)2;h@e?)YZj-Smt_P%~ha#>#jcaxH`SArzL6ZM}Tph+YBYw$p$XsvP(-eKryQ8rFb>TC--2m^0!? ze6hO@wB1LzKa^;CiZ)R6bV+{w`qd2~Ya9#ez%2;4rJ&Q^^_-m)`xu_zF@@TXMG~T+ zHze`W&<34BbVLLfS0P9}vjEzy#WG}g2ciJG$6$Q;hHSiI7vP@05NK~+0dI+eyR#V- z+8y}8Sqy;B54AzLBn)4gRSaH$iCc-o2slrAbb}H~T3(Gc)r?;C}3gxTy8xBpawgQfD(5!tl(E z{j%w`99R}uO7D%<69(jVNJv|T3##~wBz?>cZOC1|3T%B{U?`rY0*!&9VABdvK#jZm zA$AgsC_Pdd{Hy1%MF?&>%B@m?RcICKdGi~1Bcoa2IUkK~A}4_aEm{UGm)cN35QZk5 z2Otz}W{B(;azfpiL_JKjW8+rP&Kaz)M#AVk0xCp?M&JOdg!M8huhesa5qg9VU56k# zJ~I^13I66*<~igugBe0*Vj-f;J~e~86u*n}NnPwYNt} z6k_F>BD!aH92QOw;@@J-Y#I|+1^W9tjBE7)Q215$0gIbVf8e@) z2!aD_VPI_i=5masz+tOz;^_V9W`=LYIaP&tzgc+b5o z{?UPmJ$4R`>wMk#d~CK45Un#L&3YFNzVw_2P|K`oW9uEzP|F?K+>;DmNnM$5v?)2fiive0BS>`ixL^fcP zNOkf;QH!Hf&crRH*?;#2)+D0Xa*QP6LZz`;V3G-ccnFkl z4V_e@L`1&U@@=bm= zMm9Da+a2*1dj`9#Y`m2r08I=}khmv(ObofYM z-Df=-9PT8RytxpUZk@ZQt}(Jbj^IgeE<|k!3lMil)2qohi1E zOVVxIw)zI`@!^21EdHFRacD4H%I4=#7lC`_n66U;SjEi7CLme{aF;|S#mFGWXqC3M zcEDAV76epK$U_n}pk5q8hj(G<>$8R^WA9*8SOx`d#!o>x+}Ddk$wwNs7-OXef~Kp> z+^3J)m$X)s_62lCecl26r<#OaWJ6T2#PQyQqO*tH9xr6hEVkrvR>-aEU@Fw|lT~ zZreTx29bq&oWA|m$n$fmWhm6}U-;3s<@3+W+`t=q&$gbfcVEO1ub=gwQ+0@X4V!R% z9=P)nPdJAG|8$g;t^B;-$JCVBJQ4nnAA?j2bDjyZ%=T z0Cl(tE^SN!wA9fS7x}%W5s?GvmfPtPDZ?dWZlya-51w>(a4=~gnr z=LseV4)OzPahvFppa%yxVM0X71pq<(aKJG!cA}$hs;RlrNdO7bWd)_8pGkxmvnUlJ zXscnkItce%?1Fa>!g7Z&OJr*Xawy{^T~rl^(3WCkpoLvFcmbN7LFh;6a4vrMZiQpg zz~Xn~dUDKaXlU4J;PKktdn|$IBEX*3v33F!32^+Y>gl{Sm->J>m@Q)B-iTuY;f#IWnBcHA6|M(FZyi#wwWGmVjo26dI00D zkh?aZRw^#4NFk|trK^y{FxWtHf=w&G*c636n06<-9EPyi9C{aPPU42G*@^j^U;inW zYdr4zo_Z#Fg&C@`t=GFw{40@Lx<~u^)gC>qSC80-4)1rA4whUMIo(O2_&^54B8dwd z!P*IJg=jv+bQB+)K`o6jQm#4Ui!e{>b1d8w5%5%xcihxAqm0#WKA;lD#o;~k4h{N+ zArIW$>cYQT*v2ljPok-?w9Na;_wgQ&ZL`z9VASDt4J1$Hj;)+=|LzxL@=Py?A+;-I zP%A%7;vVuB=aJRgK7rPct1cWexxfar3g$y9Eo&3uWvgwNgOJX}S^x77))@Q~ za-4pOkDiF+S{!|P4T@CJ(O0eLvUj1J&yvJ0)0hWtb;VHG|Oldq*SxI z7p3p&8hENsKccr5XeH=Y#oM$V)eT}TfdNFd(-)3Ra6mwQep&EF= zORaNl!!5p{2a7xkT>p9PbrJC;V*C;I582atsye8w5nQ016y^1^81PygsBEqN(UY?z zz!awdc%rxf2A^_-zP;E*2Uyq_erKmBKof_y@SRffd30^;C5GduI$GBw6=#F=v}Z)Q2|EFh^Z#HDhOXQ(SM#&@}2a!LSDVh2W! z#>sgkc)*@gMWBIJIHq?zfBsz9M(k1t1LRqIM9ZM5N(3qXEENddd1vQ*P10P*LvbgA zkEN2?fO3%(UHYl`B5nwa%QirVVcxcif{>U5J))fh&?=gwuTjP=y{G}8>J2iTaR|IY4^6fci9$SE->}u)!6AU{h#+E4m!LQlhpZ>t z0Mgjk1cSE`1I)CClXJtaJZZu!#wL)`MHMY+dO7yL^gww{Cbrs1WMDaKMMLmq` z4D8|!`-k8-^u>h}^0L4oR^HuhJoX$=1exR+j>H@6;LbUnsOpVgdB>@wq7-g?>FzW* z8CeIF87Nq;73dyaoIIYF01r&fV#Hqx+DZX-kao?DKp3dUHwZ~qKypmw+$?!z+>&y7 zk+8QA7F4yIyXxjf#?iUtY@(+2!RXDE?|>Us_x!gNKb&;5Aj>b-YGcbyLNTz;;V~OX za8n@jYMq}uJ1g-q)=I4=!&OL>KVLT()<*Mnfc!;8+B&)@oo=0>URr-Y}NIo@YELr=>6V`fF+|a$^1wII(qK3jR4*3 zBMmb9HAnxa>OKD8Yw#}YCS_`lJ`JYcCKxj5KIxq@mQ&Dw94Y}JaNPmo;RmqzXb+hm7@!Q_8u4jl+48iI0HnT zf!6w9Yw9Al;9|3yjU4UfbL;}?xWBvkee zY))ZZ13QAUj7m;j>2w~f-?2y8-VJ8xJLI%*cf5SL9OXJkJlsM2nIA}O1B2XRKhl?b z?{Ac_xa{aiwv1QpDsWt&b8#3$yLKL%mzeaOT@sA=>cs^&w+kHf6wKh-@JszT=0(5s zmP1Ft-J_9K&N1a$w_c%4R?z09zX-41;cP&BXjWL!j^W%U#)j~}N_ReKb z0WlaHIV;Bn*x_Gf@}G!^Dp9X^|<+vzz3Va7FJ_ zTc9(1+L?mAYQoW)8(sJcGWzb5M%LT!BFa}kZijyHX#0MzC zAEVUr)RvMHEO3(z{I81uVt#8&YMOt4GyAXZeKymxGL#iaD8JGH|y#2c$$4vr)Jv8I)o&G^my@qa$@3p&sWPnhK+kcgwNXTV_ zB&oWpin%uB9UA#y?8t)+t|+Png@sQArIMZsk#I$+;8uFDe8lpx=?8MFMtp0#I{zan zP#tnm@IH}-76{^ou!4t61BhNZbg~W}q*mC=%}q0vEC96OG8nI}yaLYVRy(F{*ed{++W}?o}8{Goq*wFr?9Kg%7Z1w-P2kxIutdjm=TtWjckf1dWolO$StDORGc=0C{ zr2})A<9OQecw1h)SZ$Qx=$a;h=i}S_R9}n>Sjhk&u)(WM6xM+wW^Ta*= zRiL&UpzSW?R!~M~UI{GY>)$_l%a}1r4;e#__87jB+R&OHYWQGQ1KW~0rd?c&aYCf7 z8hC-~1R&$34G*dnXHM`uB=c9>>GZJU9xgQZgXZrBQ6x4Hq45+jPJ;qc#X_4`zp#;- zx^YqXGFU`{ai~dBeDGY<0Q;bQ-wd#pIBM$SQJn<=?_e4wDaswmDolf(n^;7tjbFOm zJ;uY$y%y27A9g)J)r}Tc$MTXYH`1tK`10SnPsgNT`+@u){OF2`ab=}NMi~4L0zA8n zy0qRU3zZKc2eOHqphwbv5!n8QUD;NbkpxRBfv>Ja1%umW)_I5*S|8M5_uTc8H*HD- zsLtH^yc{dA8Z48OYZxRkZg9xblPC2u>v4%X6pvyl_dTH5Z>MlsK)gkB?zywd@puq& zK~TyK{b~WaVY=onzUp~`oXCLlKi=2B7X;Rt)vJqFG~nwu?qXnkaEfNrrW(?!WM@#^4$D1-ApbU5f z#jk4_&&m7A^Xb24gCUnfNA{HE8kD$ZhKHE}&exxob`&56y~fec1)2PinTV#GDRUgS zIUeGH>P_KiK2H2k@rS^dRyBi$HusRkHS>tWP5&f`8IdvnbVM8n>Uhu~KWH6$KJ%r^ zE-r5%7TfwfGoFeK3iYRBXAu4ts0T05g%L#cVT*KWhRf;G?;OEDd++TV+Nc!sB=7Bk z)T54aPHz<_997@Cnl27BRp@9$lI(jENaPt?K>0dlHuz1Csin_`42h>m&ae|0Xydg&{#$17Q2M zZ2)_|!q6K3<2n-596xWcY{WSnxJX2ksADn8=^=w(P%gyMiw9T~^0oxho${X|a`W@n z^f%JqJaSf(=?JcIZ)aZVKq}jrb@yyF913A}^QXP?8s% z^|V0!0=dv_TR(Q>Xrj%)UbZEe@xyvGR_)1aazx%u3)3LObEG8(k{zv+oti>{izJ}G z{{sF`kaXh$g@bWLG48BiyfgTK8zrjwyV12NbeFwPKIfh2h=t5>DvCVNzp-0J=Vw7}fevVoncZzx;;BHQEIl zJtU+)Rxs0EEp5C0Kt8iIM^G2x((wRS>RjVeQ9z>X654XK?ue8%n!SvsYF$WMJ~ZO2 z1jrbAfzFf2#L4bNlxziL477VEqU30u9~xnoGsUK<)`@>x0ZX?_jxl}_dR!qZn9$ik zrxfJ80JXo1B+?LsqY=|>Qf%pnxVs0<#){V3xUH4%97xa&B!d1kGUVFGxS#AXqOJvn zc;RCn0%%W3(?Cv*!bsS53>s`aL2V`RLocoH`JYb2GyJWA zDFu)4eXCBCX4gS8m>jEBXkD?N5s9-VXA3J6l~%OofYkrG)-;$M{$FxuABH+$LOsyq zUU>fA__69%B+;fhXkbsejy@ZsfPw#Dq)mazF$xrTzJRBo-@?cfGTX-LY*bj-Y67AV zKRC|y6sJE(zx@W;fyKuKL{KGO`@3w=E{DSs%~DPOJLsIyBW5{y;d&BcPAwk6T$ca1 zS|GeR0fk7KW|Hr@&ndX@6##Z(%3N{31d#)EF~M;J0TP%5D7FGf;M6z*oF>4-8ppwk zmJlafbc6opBHPY&0DDY6yP_<*POQ{Ji+C`E+OLDl^Nou;Th_TR0GIhS8c4(rz`>aa zWZRdOFC3)-kxc$1&nTuNwu3myw)T!-6+e|$ z3G>ILW#}aVA5RI{Bd=KZ_3JskGNk>cC5Zq&(1*RZ2i0`<81bOztRcr)U@8EsS~7fn z--Yh2R1$fO3b%%pw;-18=~Bc2Lp?716AKwLfphQECz^M6Wn(0qQpP=R?Y_N_hKQrx8P~%4{1Blc9w}Q(29&J& zUiq2BXrp1wWX?=^AwuDhB1OxYf4gRp~@Dinafm$dwQ)p%l?}ZXbcx|MN1mjwUfbX2$^@Sn-_+|jSg5x)A%+9e*En4c%kXA>R3X=O#@FB zCgs87Y;rHudBag&a!?!&5O*PZ3=VIS(g4J=D;|tXAa0?8G-f{Z03a`sfp1# z9V<|8H4TmSMg#^|!RX>-LtK*(YKY~-`cdqHlPxlJBY4d7SaKx=D8$2R!WqBa@!Mvw zAumV)X+7|lqoZPx#5;(4FS*%et~v5M;P*;6GW0yrm4lly0if$$>s?w3@^;Z6h}`Pl`tw|z=ByZ zdkj=^5>)AGdH745f|BjmP_5^}YP~lO&dv&WoC|WUVSvLtl$Y-NKz6|?tueF+XG8_& zN<{z6N35GAn2NG)s|{P+m5(z3_ksL;6-HhlC#HIrho0Uxg2{lk$91E|9L?Ub$GZKVD<*dfjPpffmt;Dp4+0ACbgIPp`biDMQK^B z+5Dj?$8T$#r;&?peb?T;9ke2(%#gRb*LuxQ-dM!4D=1D}mIJDliw1$> zI74$Dy}I9Jxgofl_7I%o*5vj?2UfZDS{j8V1r*I(rN9H3CN zJrO`l!2tlV$Q!)!S}2`X`7HHeWOVa_w9wt{Vw)7Qva@?IzRw4$FoC2OF;q@gU# zy#g<@7Rt|TNsRGxK;Fw8X6H8}NDM=p);?kdPMP3;EpoHNO>1J~;tEDd2f5 zH-=84v;2Yc_-S(15e)EH3z0M%;smZ`fKJ1RMB_|#Xjm`}T_UvM#(>=INwXnGG$Q_} z+=SwcA@Dz0D@nobrZGQD;79>H)~tdeERoQP_G@9fB-p*Xis#=4N0Fo>8hUlcdK zAiw|{#QZ0V1fkM90xFCox&T?Ok92MDa2VOoeTWe`W_bx44B1N?yhyu(rfdIk$K#SJwCI*~q9i2hf>cd!Aq78)vnh?aTVwtL5jcZ%ro2Co!B z4^#}!B0C<{*9~H;bVuQ{1xVXqQwd0xD-&HY`l-OY+#voc9hz4Jr{zz`RHt}6k|7YO zH)V*y>Ut4M0TWYlP(n(o4|~SUedD_TtQkqQ zGYwy>c#=r+j;z)fkY^Cv^b4&VP@t$Dp)^E?Tp#(X@&e{MpFHCUdsKhO`=+MNpdmP` zG%#ruUoxtb%yWh|RbP=jKf@Cf4cTIDKTJ#`=P(dc36diyAggb0hLRx`wh|k3N34dp zD{zr@bo|m?Wp8(8;hiKT6IJo9=USp z3LVDCcBT<4&cW4iDDkQ(q?RubKy0`EJ0m(8hz^CQ|I=H0DY8y9=MmbVUI-@!IJg?y-%fyPMnFkBEb1+Lq82 zLPC)hmcabfHMjy77t4p}kyA=mi|3>x%Pz_hU{6HgfDrr(LsD}-ag8BeW|fU6a7{0x zB*Vf7%7y36o};(_`Et(NtqG}PYK095 zGZvMGCqw;8yzG)!D56?FNup{~_*qJf=kB#KXRxb9Jl2LYnLchs`T!eA)Ymbfr*GRZ z)%)2$o?}cBel@Z4N~WvUt)89F%9Jgb3HT^I(i!@(SHy+4TM6HU%)-wUYhGldQ9|d}j(V4Z|+eXu9 zHz>hAD z`Haor2-4{6?d=Kpw=%u{rXX6`*Em`+(~%x6j%XpPZzEkxH_N@Qg_NaCqB%~YtqGN# zKFQ#J1_lv=)h4fJm%s^ysZl;qVk+WD1D?uGdt3HNmG<8Ua*J1asC<6fUeZ}mr~Y!a zj_vBvF!q+~0I|^eZ-3qZ*-&6EDvFh9+FN}!H?;g643!m7(=NM15{wv@h}dJH1=}J% z(%Ip4?3BOp;?U2o^F|;muWm*}-glI=jKdZABZ;6ssN^E-eKik}4+BiYCo(cJFPoVj zOUSajOoo(41lqhLMa4lkYEMrm^Uz)rl-enVw2CRk$!}@fYl}wuAYj1x?oZTsoj03E zQw%}rE~pvSLhV*vsF=2aJ3orEn?_5)lkFjuTmWBp3Q>*|^#!2Q%Tb`(cZgo<(Cm8# zyPQwVjszF@yn3sGyShKr;wF~ViAzaU;%ieCFBF-vVe501z%7^u!4BI!cv2*E1{#iG z4D1?oR}+r>nD=?A?ZitUf{fpez7qX&Z9LBP%$D|@lim8CKR)omvU|vRbL$ol`A58Y zoYwqqOg3D>Lid>I3ai4>f?4kDJsQk%g0b_%SC2I>9#TK#EIrZfHOW@uaJC~rq_Tcu zd}2ZRgF9nP%%tZBDGT*aF-|E?7Sz<#CSFk5R=x&eLpioZ)|7c225wVuif$iYZajpT zX%j@QVR%@o)&2+;66cKyE_n7bpyqWbtyb+?MWS!8UpFikLs=Y(XC#*!$E2y35gxWAfRD7-`abSPi23QU zPBIyVq~;jdpjV@nquRm3QUO||!Jr1oF8j-_i(c+kQrC#ZhQ{Ob;O`CgZqw7{=sl6& z6fv(oh2z%k`)9Go2H5_T0!@SDBwm35m!HN53%6{dq zW=pcD@W~l*UIT6P8=wpOT5)N&=54GLoJRr*Un@zAMiLE^&7IvcEO5JIj9~0yAL2uT ze{Ta!OPuV}EXEv=`yYj{vR7PLS=m%yKgPtHS!uRm*c|{YDWRAHS1Dypy}}XE2t`Vy z0h*Wflr-J-6qC3AaQF^oBSn50giPxw1j`^}AQR**CjD12X39}ocJrW69B$2wQq6`= zq#U0zsP_wKE23hRzJ}N0xp}x!5Eb%6-N|lbUakSwq`bu*`z*378gtFQ%WH>r!75~m zf<+KT##d3O-8aER>WiyJnV)QL#z2zW&CPY6gKpg-qmB_@xtg65j8Zx7UR~#tu7H35 zL8SeOBisJ%1>tj|1vFxZYhsDJ2zngG))#055MOYhm~dc@@uq+ zJ?H{n4qGwhOCTeA(f@NpyUt#%0}kEbTJh!#`*qmjr`H5T>Jw5wbH_~yu4K^ z$a)3!<$KK8i4Ex#9Z@1vRWHAM1;8tEKp4|0i#HtSVvIDWY$k&DGh^6^wk@5zTQ|Uf z5ofVQC?Kgvk3IN-2nlQpQ-v&gs9`WjEVJ!Tan|S54KZr#1_lQ971a&=tUaASpNOSx z9iyG15fKrQoAF@gvU&)!yiy0`QBy8J+_XP!dpqE$XO8KP8jn+N>Z+>bL#UH^@0S&x zvnuz_r{=c|q!C(opI4nRmwwbw@_v_d7;IUMQ9IXXtvLXv zX+A&i-+}F7?bu65(g`)~t8Qm^5IszE0m*@;I$M6^G~d2`6)^O)O~vEeqv79Y`8YW_ zmjp&v;W1tthsApMj2f^u@k^83+Op|01r!6)J!Mw)#uNM!vnxOvkI9=zL*At)rr z?AfmwSbi}BIGsHno(sg*(>L9byd9hyW|?4$tZbPi!=!Jd}AYOZr1#qV!yvs(ERR#6(h{mVf+sYCsmj z17x(?4@X&7*EyD`o&xM)7k12my8V%p9@_Z(wA5xBL-s;Pt*D;|3a^BJC=2Mw2^YqN zH5JY0+8e-mB|qt5;@tm3)t7)%xwdUn8KVf1lA$t|s0d|PqEO~}D4{{5G8JW>LZv~X z%rdi-GA9aAl#Gi_NhDK>N}1Ak-uC-`|MwrqKK9;aSZh7c{oMC;o!2>Rzd(vk42jHj z!)Cu-qC@LMT(q5fpH^1($DggDNBMT7c{iNgU>p+I<~gl<=ukiCSz%zo|8Oh#O`L4M z-NV+fp+_Z0@Nkp9ZnjHAUj_Fu-wGk_Kl|1(#*-2Uy3qvTn(;e90gZGf#J8?v&9*6N1V*e^9@?E1KxD}aIAh>s@`wFXCytlDuG zX5VZ9b|b&l#kqA;*?(_zNiO20daY4U9ob>Ouq9LSCjvjgh9zFflRqphEd#5otNVxP zXC71%QF1#m;DxYRP60ymp*6Q)!+s+!jv2jrD4t_+2?_eUhs??o#6#6vHx&7%F-Mr~ zWl1&gSm%G0_P740#*v@-lE!R>os{a53|&dDCz zROM26^wock1{wTZ?IQkxyfF`;xoY4v*zkX@zvGK&j{kelh3gshv>F>5k3v)e z+|rCk~E-8ytuhGRQ=dAwpvr95sPlG8*M@ryEJ|@$s3o$21L*5KTT*=h#rl z35&7-7bXxr(KFx#DGwhS9R4TW;3p$q{?vT>Q}WWRnz5~6QIc3}soPzpwr{_TI=2S& ziPZG;709BJEX6qko+v*_em)AX`uh5Mp%@*@m`ivIn7WX+0D)JG|AD3bo$uh%<=mc@Wvey05^?h;XVwD$ zx^<^Cj|VoCrV5`;nyKmTj*Bh^fM^Keq_J`b zTQEwjZje(KaH<+e$2xZ;+ekMMUKHSx77!uUg&FpG4&MtQ@!&DM03s=W3|=b0vL4jR zHMYC4ks*Jx%7}`o>0`;|Fn`hUBtRXY^ExFoAZ76I6s`?%|NNSQqrqMus4qdmD9V_k zN1Dcu_V)*%GRw(y$7WtsS}I1nsgpr)5z3ExUbeK0$qqzi3Rum2+>fnxDN7U;v2n1J zAM}&kPz-S>^A{m@3U4^?oeD5Ca;Q<2@U6gCQc)u0R~m7X{IS#TD~f4yK6vy zYS2Q37m=P7vr4LGHM9b>txHV&d0DpQgiH(@4c4z@7%}aqh_TMkXdJ0J!&$j^><_@)U)g z7=%S+93KKv9362rWg_J+oWyhNE)neCmdR9vq~yU3N1m zX%&cvt#cGDdwct#abC=&h>I^LJ?IZx$YxsmwOf`D-O#k3#&W@%qn_GB3hPownAX8E zK(y}y#Cgk+ikBA&@+Xb&8f4 zBD}zU4}Y8YEqmN^D|Rcg|F-&UDg?=J1(|XXq#t-BJ>5}ER30LZ2_0hR!#!Vb-?>wR ziT~R%KOTPJc!zSERaj4(3bs4n3nqiD0mV(2Mg$;5ETb2CyQ2^iXb@Wrr*ahIt|V4A z%@_8U7K1jIp~+Ii-gnasDouj<{JL!=RLRjISW!&M82-n`u# zUiZJ?pKn3b^X62mjE;5y{T5Uqo;7QNFgK{IFO!5iHosBL%d0HKkAoAY)%(6&w&P3K zaXiu}5uc0;9?Idx7;jJEHUxOe}Qo8ad6F^n?=NLJ1E^10E8ScJJR`1y-%w5n^#bGG+is z#foc&sJMQOwqNd!hCwuOhS4crD{T{w`%z~F)qY%bw6X3m-U`kOt^C1{9|OTo4XPi- zCGUnGuRRG<@WSXK!wM5kdsFF?uj5iphOetGs^O`yPejjScj(X|xkMahkOl}966=rs znwnGmA9DA4e_w$cruQWW*RbqVyC_9@D%5P1I#z9MMe9*MM)=Tdi_2QAx^N>#FyQZ< zws;KpgPK9H0|8)YN|t^uU*f4vej1&W?#a7y zZsi-Gp$rF@65}0l^=chBV7k_WF-WkqDLs>D)fAGunHz28u?bC)n}qPovqa}d$&^p% zSySkOS*@@p^F3kK=?Wk8pgM7ELQep!^^gD!V&Y377Do9h2Mlf!;{J}i`9{N-^AY=6 z+7ix>W|uMmNKb-9mpJRAscV2&ii9Ih|M23d!a`YGW>bn72A|lfURx0_99GqS9Aow< zwJgWM2^_$~zz}f(w0+{!TTD!DAoYJNlr*Cu#Uy;Xs==`M5eKWZAKl{my_Nb{StqFk zVjD}(%ygrzLoQ^Hm^6>3rsUSG+?)GO`-OU|62t|TF_wSErv1ME`eoK)3CY5+mpS#5 zMB_((TWKOJ=iO?NGP?CKu?vAaj`7{=_#X$@gJrs~sO#xP)$G>a6%$eeGt1~?5MCrk zFcZ4v-k0ka9hgT#idIClqKZ|xl&fR`$G+_+slSTB3PU@Q`xA5`v zyS1I_t1J&{Cf(IP?Ksz@JW=5(j5ANy&7%fRP|msNv{|&{*9#97%0yy)n*7-J=1ZM3 zZC)!^UMoEl9UIGp$Q@b{yBVx}_kl1ADi=MS8hZ4P@B>EkBgVdmixFiOpNfwH&TYpY z){M0tPyM(rE!Op4f2-(g|MI%ZefR_FOth>&c*&q^^Qp+s;n%K_G;OCIM$T|%AdJD# zzpH7Gf(~^J1?na_7hMBTSB`?ab=1M(K0$6%rniJHp3B6d)TAh0iaXwY6Dr`)crz z5M|0FIP1+H7V%hN0LKXC*0O#3DlR^?;2c!e=(V0UlIz>G*t1d~WJKIECwr8gdc?7E zIv8MIEz0IyF89q)7+K8zC8sUAXE&OpxOf0MsX)+Wix7`R;#07zLR+Tv3{AoKC)8G4 zMU%(E6S{KSgd1A)>n6|lv{ci zKFutt%(y*%WNu83S2Q)JdLng*Sq|7{0K>=_0UZg(_vdK6phY98y5Rfr%XEKZev|i2 zvfI&EC3v8lCk}FxIFf=7i5j;YfdpXzDS{9ld3mW}mILOZJ1vFzGT-nYqqo{F;wZh~xv207aC!<3F3+}{JGcJ#N*6igDYvki_(K37kEvYO ztm)=d0xp4HnqdUgKLB%AR~lkvsZ%-nn05o52#fxO8$zrEY^a?&gw~F`0J-OuZbk8m z5oB%8`B;0s>PI)g=cJLwRC6n!hBQN2eL1 zgl@eI*(x+8gd`C-oX#q zzpJHiVT{2Duw83G;+dysB9X+$Omz%F7FB z;PwhM4t>`lrrrrEzDmxC_!NVfSFDI=NAE#q6vbJ@mj}*G+(j7pzp^5nMDS9xQ;6j; z++UAUdDg9q052v+3GnC)>gk8HDfpTJ(Pm!r4KdIOdoD;j>w}E$HCWG1**g`1bDV<{ z{z6e~wH$}p!t5r|c?Zqie07Y7UF`KyUU)&gpJ9dFfUHigVHY6K7y(%7Lf$J_jXVK+ z0gSFtqGTHB6JI=-Epj+xSg;Dap!YRbQ=Fvx_VV#u4w>!XgR z8Su^|wkuLX9kotf8G}^3Fi_6!tztseWP1J{K)fkDMwO@FI>J?3Q`3Uayk!jw#48&> z`7j=)hZK$mXgCGT z%L1$&-OCed>8R^x;H*IzLy(E#&EX)D?_rg=gEcbd2Tsli+(ec!f?6aB!SxY zPEJm8iD_vfknJiLiea3GXf(+OQ&b($znbHBU+*?X#V(7e69avp=R!`;AB*$cDA)nK z6ndVOUp`lNZQZ!nOReYu*$Hfg{1z7!r@qIa<~i!@oabTf=chuli?td$I*RHqq(B~K zF0L@q=q&(=n7wxIPiTk;Giw=z?a7^xK2}Qrc#;^er`gV*6uU!`gI?RhMbP#jjvp!}KfhTI`u~=mQolcQSP;uIdNIpE+Dq(ArCdxC8ZZ+YLvbr? zM3KyBUO|R!NqfE3(_v<~47-vTUKV{SS9Oi^sZ%Vd&e3czQ8$JdV64Jhw~P6v*5(#S zaq*ax)bI5nTtE;dfP)C&NNCl)*R>h!iE~ia6enDfZ{TLtOitlym%x96F^RxT%dn~4 z$`nAV$*R3r;BudXNgFyB{P0_*_!97SFX%eA@B(u~-%3SwX#J`lNY}0>N}5NGY!nhPtlYM3o9W`R zBZkt>N>u|Nut0&KnO-DG=--3G<@{o1|Lib`Ps+*3X>7g^Obd@Y=PW)V@gr|*$L&t3 ztV|I12i}I*jZ7&a5n^rwi%p26*+59^oGT(Q(MH2!imq4{BrfF>WIz$MmZ+i zRLhX)5`Z6XOvaBd7T@*JlgYXE?w!p7vM0jDx*(KRpX_uXRxp@o*F{&M-A5jJd8Y^7 zN79}PQ3_|t$VJQv6Aljjhs)@Qo}oYb*^8IhKrM6+nHL*rAnA^%h!pzZZW9+1lVLKB zS|DHapBD`cQl0Sku0;Z(J)zCmqkoc1#NlJcWrPaiI{4N)%253|!l}Yfsj+j*> zeVa2iq67Y_l^4Ekgo}-y%aoVDrV0}iQ*omCdQ=Jqy~Vt@(1AYAji0eeP!oieJh8_a z@Dfz=&>D5Rg|Q1jGm(KEyD*Rq?0e_O|Is>_KMd$-z#I2RT6X7qRoqIkEV>`~@ zYkJr&j|lkKR;?0^Y6ttGFeN2m(#dTQOSl`300WhOFvhVkG7`xaD@-k+XknkpI$n>^ z#XOJW>?%aQh3C5P*DjzRPKhlzC?1ltDyQV?5lnlthAg4$en8W^k{NOBnkKvjX|5HJ zLPT&6<=&(Ri~i^fhO zkrCvzW|PzUkPRw6ab88%a#Zwz9K@M-@Y7|xIHz$F_czvvMF?6IyUH~*3(Xkw8Zp^6 zwA^~Ghv z(FW|b{ZmmpeJ^jPZqi=MA+Ft3-u@i+Cr@VGad%o-A;bLfdoVlQQMBjH#Kad1TO*ww-SeURb$V)Ui!(CUH)7KX*@3tV}Ny3vkv3*3v1yOV!MWHyCnzJ2g!vzQnR z$?jP-A3u50d{zZq#d51=?NkG9&PS`VCZgTy`ak;Kp5E@cu9#hY%q{hW7p2bZoJ7wR z`P*j?Kk+Z7huH2&wYMP`h!)g@*K>7OV!z;O^zjf8~!Q|q}S>r*D8=UR&wIxy&uRR8eLeS_Qg*@vvVzSUZCIOOf{RI>3(aoU3afcs!&Leg5)gcD?h* zPoGv`+{pyA4r;B<7tISV@O zKptV8XS&hc!h#uR%+Zr48&1-SA~E=X`TBMGP;8o6ompSqXQqG>{fdzP4!&l#g!NUA z-AB!eseZOcFDA@(&^x1G2roi6EG#9JSMLid!FgZ2kR3L0;y=ElUFjvb&HdQR?v+qd z9l4jrH&RI<;he`exnA64P$pFGc7OcayU2CmWTMsg8mb0A`XhJ{GN7n>p`Q3QY;3lO z!@kmYp7^gmr_t{Gc8^R1Q1r67dF{|s?bK{8*}ml!eJZ0?eopExT5xaD53>U zKcUs2+ypK_M|uLQWWO zM|Ag5j?Ve~2|yO1csFY%dclsGfzLF<`4z;wo{+CvtB6bmE}I#>!1?Wf@@pueES?0d zI{V#5jBv4hHnouityJ{d8uMfPIEuymY$|aqJuE6Rsc=E(1``E(5t8^!V$fsGNMUS? zy?|?!j_%9*C+0&X7!R$fj#dFnka^WAvx;r7RwnW_5dl;MGeFo)&LpF`TxvfE3%~!{ zH~pdq6|Gqf?>(PCdq%haDjz)>z@2avaO{cdM`=xb)rqE=-Z{Jg z@~uzTj?Ue-F*OpPNsbNYH(dCK81jh_oSK5kTH^%P_og;_I&`a-8^x4xRtJF7wPuuU z<6b`a>*sh_Qz-OT1yz%}jdh3IFu5S5xJV){GzCy=#M`DAU~&{xIIUF>EYm?}8YmF4 zvUoL^0j!$sEfi+OBS(&qQ3;`NX>E486l^|$S+>IYFMPlOmxGHSHxT%5UElJ4Z73<; zJwKyMB_ph7IKt&zG!5`gcIXxP*9V|yh^(R{liuNJunLQvKA3N0oYDe`rmpqJB|B!c z!Tpduwq~#H*;0Bn*zWP;Sw>ut31v+3%`w1qKv_A<>W=4uMqY8Afgqd!Z0| z4rg^e552-k=piUNIyzJ`tiyp}+|(!uUrM3arii_Wu`wND)yJuILQd=$K105bn;pg> zb?)k51er9mkZ``K1ddCWE_EXwyYPTZ=qmMY2LPDZ%7TG{m|4pnh{sGf5cJ$<09g*2 z#4<;e>}tRSC;%Wg=LGJ7#mI;1>Ss}O7)+2;Eq7_42}sXME)MHv1?HnM**-+Hgjy`s zZBM*kJ2q`a>h~96AW$Ilp+94L>BVU$Gk101l&K!QsMC)EKhQ`@NJuz}_n|SIE>3y+ zqkk6A+Qxh{?~%TzPmB@)HrQi#3O7FqUgI+~`X=oXplU(2Wv_}!KOg+DJ>t9^@KiWD zIq4)+V}?MoFH(|J=0-M|xtbS|--(&O>L8sqjL zD3OzPX=DdZHz$EAK!uNJsHJIbc8s$=hLeDgu94a`3jKJ#C-@(W$$x+NuXq)2(QoA6^3xVg znL4T`n7Yt@9?$0j*Z@lhh0aatTM8dz(;@osaUU=Y#3v@FC?+P{c^S1*x8OoswM6ss zafRt7aVs6K1SI$o@0z zCIq~U7;@s|Gy3qn!gWD~BWaP~v5uM$w+JE5k*KD|#vs%IG!p%MBRToOWGU&!XcF7D zZ8=h*8;u%=0(#{5AWFv-m!87)bt7)9rJ(t=t1pIntx_jUaot0Kq|q8~!thnEGJkNM zXqP}-PJ4h-PaTxl z@j)Ua2bE}&CQFZ(u#xf@oGYbMop=v9Yn{ z)_X-Tp+bRU=?u(7qQfryWzL(el9FjIq6Yu~V^ezBuf)vlG^y3fj+PiwjR0sa@CNqf zH8x?{rl5i$vaxG#=~E4UuB+-1bIJ7=kHqeGV5H70QhHz0?$FzKjG+7r!d$Rb)l?i9 z1j%S=f6#<+yh!~jzs_F~CFVU>03+5J^w)Vg7>WB(@MGK>A(&$lsrN?+?+KT+^-eOH zBegSU3Nl>`#I1t)=B!OW{?HER`;ny&7+fQteM4xPR7>RDnmh34<%26J#<}!Z{tU$1 z_R*sqy1hovkTgHE<2^TPhLZi4vs1I1HLlLKi?PYM1gNKmueOpkE+aNy3edYjB~gnP zPKDMT2hsM(=4uEP8Z{Bx#bh0Ibb?^nAf_bJ6JM_$)ACrpKnngrl2ai4tskQ8bNSQ1@8T zqr&3v=hE>xGINl6fq72(d|(-ji23>Wgy=ZLfJptQ;;Kt=iX zn7|c;{GPkhBf*Pc19NkanjwFfHxP|x8IVBg5M*~2mw?|sT7oT_<79)2n~BjnV(xEL z5O)JRjBA(at-}(}Zvw&dO8yeok5Le`1T)9T+EU@Ai z07@CC0GID$*6vKj^TK?m8e9A0Uj&>0_G;Kd0&j7IJ)#kd0>n_6j$sFYazNEN5u^S= z9D^&aL`!d4w9;q5Rd^}GW=Ids37FTpCSvgZ1zH}yvKPKomel)~!^LBprce*pCBY4K z5=3%6VODpfyjMM3WiN1wPd8xl-ti%<{8zB;D4tStKk&vMB)b308*S?5A%N zvDCwV7Z(;9mJ_&_K*%2O5qpjeXH*^yHO7z{;B^H+EZSOSFT8#G^_gh~fR>>1b98e) z*@3=~`hDswsOVt%ag72A&mKy2lOJk>OF2;7m^0C@j^R+zPv3M?>lqwryYH8X)OXQ| z*EpO$&4!-D0L}A`yNBA(=caCZBWsHf4un$;7qGPogO;Nh=Re%ED86kQRsOFw5206L zw{+WM`$x1&O~Xu(gZhdfvEkLa8oh^HQ(zYY2Ghr7c--rl|HF@pu|1SU0P3ZJ6uWXk zlqu0S3)vTUvfcjk1l4rVJdQf5jo%}ARooqz)Z!)lRE3b#|R0WiAvg542?0s z?;^Srd^GGG7N74aV`qR2IQ<6GaX-l;mU$I*pXY%BFH@VCVx&W;5(9+US1N)3W30pZ z!us&gEqxrKpnTpC??adPQ&uiCEts6I=+X@5KLMHn5hUmm+;i69DsW6Q!H=c$yV6hW zXV{glW>=xoF`T`37KbF!bYxfKsUT^VJ|{J02#{axUrk$7d+|5#{u?alkg_s`G_Ux$ zI5lV~R{X9LL(7xrBT!u8wHpM+A!p}Gcw=yRp2N)7Ec!0yK*HIAcMpXjpn|DT41!8< z=Yx!>c$&zCu{8Y>_O*>J0J@H{8 zlpb+a%tvAM7>O+|=wc**Ry2PD3fpbU3?6n?ND`T$l9JMz1{`L(KyrH8K3S zFHn4h2Nih3#p7IIcIU8X26>~(T|ds)fOrI?N@k|GBisW_<4w*~unFu@3ufq{Jc5!s zs5lBOH8J$M!H40?Ag`^@^B!Xq{W6$cQO03F?npoqDJ3PV8ek zA9cJ$Qj4RHHVZ@k8dU$fy$=|BeOff4m2P#z*+dxe;mMv=q9|*-)jnb0t&?(inruQ9 zf60Uoz8@F~Ey-_gRP1)sP>d3$7DNhONCQ1`HHoLXQ8}fJB&qRAEZL7hiC0V+A2||* z$^P+)S4~X^C!cc)lEDRBjVy-G0?G~YUgJ;AS^?ZbkEar3pQA}y2i}+(6Ii#INaVBu zQ-q$~gHQ@r^qd<&vk{W5fYxYO;Y~866nOyX5S5tl%mZ5irlAuItQq=qQcMNGE-pxv& zM-er})vH$(hTa0SEyuu6jx>8R2nF!Z=IiuvVmM#;`IjFgR zFz-l_a9`10Lam%^nR|48WVLlgFiJY{;(by(CMfGAO{|j0t|G$dcqx5}^^(ReU@bpl z<=1xrUpyS8N;aG#rk;(Xt2{W;?ssH3NY$^mS+6u^!|70&m*zYafv?vwQ@1-Gh@D?P z$4KZaKLz~os|R$OxbVZwtEt;1=2nouFA{0rSoAS=`@b)#o;t}w{3nH;`H~rt z&lx7f=)W6tQ+q0A@V$|Isu&Xt_Ae|i* z3KQs^;XgV6j#OgSl(O`olTNxY3!;vG6n`tGiy%eWwR^YNuU*i$yngix~0W@=n)7Cdn44e5O-jX z!$dcA`=H7!KnRk$pfQysW2as$W?>&#^<9l~Q%~E5Z||S*5*`^{ahE&c=n|~pD>)v} zB&eBu1B-ht6zrm9S+LY5q67#p*R9L>9?^`mpdV-vV?fW~dk}M(SFh&Xd)CJgbZUfD zZXVk$%Xa^c{140yAHr^$E?c>eNGp5|UF<-7G%zvI;Qj`81Ne$kj~*$yxI~?{K@%0p zm-jA;%08&kzO!JF0zONd=%bSiNK6L~9metaCf)`4{(v1?4=|ODVBKlki9Uq{RYozdc zBDn{Iu?(N61HPEyQBh_&)8sdz@{Abx2?$gT*M;G&EvYYuLlVF~8H(IOsN$GH(W_0k z0yenaSiX}?qQ9wj72AB*gbfDbemG-D_$X8e;r&Uw-hk3YYG5*vFxdf~yRNm+3D{^5 z${Ahu)1-M|tESC{=nI2{_l0qcVrO^;nn~xK1F}Gc{1Da20Ksk+An0e9YcTvSf`p*E z++ECY&u%!Bz^^rulnVHX7&Wbyv%8`)uMy(O|8A_3EaNASy*h}(H` zmEoQ~VCuy0Wp?#djud{}mDL1UdfQkE4>R21BRO{{)8zJr59 zFlxwxOk)$1Iwwu32#p{-E1%`6Oo=9$R2i3cVAMJ}c`k#nih&1d<(pzw<@0B*IAzuu z8&2L1Vn}2{OzDYWB%h>up=3A69YRh+qcJwFP&Q5^0^NrPL!lrDRwOAPC8-XbzjWTX z2A8gS7Do8GAJ%HXS-y*otzr2aPWVjQR9WC z^z`))QC;?{#Z}Pi}#+@gp3h>)R4L;#E`uo zO5GaH8}L`NwM4hhk^^U8E7J5K2>dV8p66gHtk@d zN$v)G1SK6^%l~d(!-8`0z`k;GRU9(|9cOp!+Es@QV&#&vTSD)lC@Bf45veH!xEAd&>L zTR9gK%kBh%ui|t(;GkdV*5!2X8Z&I9G0)(GQdmyH1<%OZ-JRE9g&ZI#R2+g zb8=yhoimb-bssz6C}lIRi`P#m;L4G=b;;~j)97o=c;qL-VmdEx)9Y~eQ2nS<&U%cR zJzTU86%HTxvMBynP&!y5wRQSC8nAw79T{;R>RP9cC_=+11AIEMdU!_tD%iUwrAGx48&bJWR+6|g)Ra-NdnR?fhXMaJ!u7LJL|A|7fWEtWg-YYGO; z;$KpyXd6eiU%FQdQ(@E_%Q23-SEAm!lUPH5gGn^&_zh5%a25>!8{@p>SNHY$_rIKj z0fxzq*RNk+eJE+~I>07!E}>~+nBLW3Q1Yyy;Xu0?>Y9mD>t1}`_Dz`Sj?TcGV0T8*pjuf zjt3aoB@TzmX=*BFmVwBD4#F}fd8lOm;S!M+4Eg(WyaOR5kbx1m%jJL+r-LsD0cAwJ z`0uB!>;Bl)@Ev&SG7`}#AiyrL=X^(zKx9-a>JhR9YG`P*&}#{B{O1R0io!mo8-2{w zZHnt=BD?1&1FJ2uRQgCC8h3v>uC@n-yvygT>q{AuYXQj7#O6a#N4_S zv?d}X2KEkdw{E4SVDK71)E~;2f%2n4;V!}lwj3{m&imrU3ZbbxuZ|%?)RLA&!UzHO zuv($fSdsx)f#RlCZX>O=h{fCjCS7qB;eaL*6+)$#taBoK1wLW}<}QL^dXrT{TiY6p zz8NAoMFxP7SuPC*U|sio@2QnASD1la!NS)k-wZIxjoPF;mx#U0@Avx)c#}c+@;&M1 zO27@*5mjX;a0Rq(2Upj^AQ~z)%kf>O+B6=FWkOkj!ei&Sf-b;ROvxFhOe`$IiIB@# zA29!#Kfag_5d};B4;m~ez&S8!X@t=p_&kqjO75T88jn5FNd)=|k5@tGM2r|wY#A=? z8WiRMe*vN&~P4O=`CSL60qfN=3S!)pm?Y!^CA2_zRdD# z(^XM$TQSD7Nb>^L1oD>Ay305dIJ&T zDFc875TeAI<1b%6aP5yMI@kf{3pj5wen;XH;TCdT8vEyQ7VVuBTUwHGZ~Fg&G5ne? z?qs<5pX!Jq0NvGp{}g%8u=j%c&p<2^GGJ`20`x;{JKW~MYl8pTHLW}}_c(TSiXQN3 zA)*gjDUOL=l;(s{R{jUaz37o`$AQWng&-5(Q|A3c6ui<%#}8;X1212`+~z{0%cvbh z1hCvja3VKOAgD1IAgTxz7<(WDF0EB)MlKPGJBg%ov;_u%zQU-23J4l_31x{G2_VkA z{138SZrr>{N#}vK1a4`dU;e9Kh#>UK6!5f&b`8`J@EnDEx;i^~;B5B5Z5~ta@V9#) zo|f6gT@nx4E{Tv4?SpILbIDR9o(o_U0>K#8AuQiFscbjL>~A?_LmKmVuGc~BzT*4} zQgDe&Nr4m!7z5g0(S&vEZ?#+3VbVgpgzLEYbai#7U_E4#OKK%TE-0j7d_0As(y3W? z_ZluP;v$SOA0n;^>Rjl)-e5e^0{UMY&b>2WrY5~?eDdAazv#gIj ziA991;{zl8zw;mdw!D9zXyJ^6v$#8VL_M;x0rQsws$pbgT+_9j;JamIWll;s4wo2x zO7HNj6Ovm;JNhllZG1#cn6Wa3V4>jpM|c8yn*<%JvSG#-yNxTMG$sL!xq_qKD3_4s z%vNR*b#u^Xc1~BciTQi~fZV0y34~N_FUtlx=r3C1Zrl)Z?1NkJRU}1kZnnmhxY)F0 z<}Pk$YV>^6n1uIE!RvjEZAT~1u5=Ok`Ti{ za4EUOBt2&E$P*Mg-xEM2)=F?t-8{A(lGs8^g7R?^%aPTJa0~L9Pik6K`IHNJ@6)h( zbd1GW6|gE%tT7~_TPU1sEf5KXS3F9?6|Z{Ht6sy9KVMllQK`7+q{Pesp!=|v%i8BTI@az3{Nn?n1&Ft&qb58Z}znx~&(4e%6|jd_gIbSamc zE*z9>LtiWD3C_(6OA^O{&j_|ue$uaMfO%=$MTA|g-GC_^LDTplrTYG9d^|6ucIZHV zqz5Kal<)Eve>&>^P5A-&XawvIy6$DZ3%@pQYJQ1$B!s+ngsopp!x z+lS2=lauQ1s20w0@+&ce!cd%cmS+=os2gcb)Aw&I-&)l&G&8OQiEJxtQL0GCmfZUI z`eYzEx9{WYe0yg8L!b)6US<`@C#8Nk?5+96pE>Fo2I4g$^>Z4ry<#rmr3J6$=w|L$~&@HW7J!Z*BSV)v`jwtCYf(Qgxmh= z?qsE&8>&?t@q%=?``5^69AcK8fBnbw zbSQ?Uq17aj2M4KzGaMsBr)9@7v6&EC23o`PL@zJ@F2|I~QjjRXvpsh%fG~j;s5pU8 z`ZZfQgE0ll^{Y7rfNd%S55nPf!q)~8HOT*|9gA@yiN$jO$O`x`0e`4+cz^H@ zC|D>3d5BF=C83x&USUTF222HvgbomD!<+*4bVqnI*L)y-C7Oc#Vj!%q2~1Vg0KyQ* z#*GD&r6Ar00NZt@B7b8aC=ix{2h_cYF0hT1Pty6{xnt z>ZUegQYk7Xwo*V6)$@M6q`eV~i;H#BYCyH8prh&ZK<9D}lGQrT1R%6$V1oUx7Z@&6 zp&7Jm;J!_59Q;iP$4D0jPYeADiMYtH!rq}=xQecS_XnR% zj(r@Q!>{kw<_g#rJH;3!VuE}WGl+0cC}Bd-yYuWii}PB8u)G(5k*y;2-gFU23%7ML z>Rbsa3wA&zGOBDMzUD#Oq24FP@Q$_)4ukyu8H7AdrXN~)I65$i*2d}X_5BkIbUaax zw&2qeE>`&;jQE885|l6=y`ht#F&`O-ky|Y`6sPys?fA&}9u=O{`y>zvk(kBXstC^X z5(HKH7p6Bi?%mT#pN8qX+5B|0da9!;Q6~ar2h|KEIusbj6(A+@VwS0EaykCqJz~yb zdIlN{A&l+1a-mJZv^^Lmb8QnCjt1fLcjPXo>mM1hn0R%SW3SH)2efxuMGs+G@%`3J+=SytEL=x$jGd@F=VS47mxpQ>o zHhSsPa6O2T3g33Pf*W1nzVw|~)fSarg>5brTescdrQ_m3aJn80$JI$U;+GE`ZGkN@1<)>kuqr`P1s?ngs^AN}rgHMIHg z_wTxkJd})ZhA*sX_7P8czigp7_VFLw)qSmS{|A1#U)i=#46pKAr|oyR)O#r+yIV>! zGCP2q5_wy>TJ?LY;L0oS_I!c+O!JSa?#rw1?s?#wxbx)K*NJYw%`;1!NGkJqA`Oz= zz%z?O_=qha-e;EJSSLO`$lkjGZYas;DQ0J3;haB{_0YWUacZmMB#_^sdw1#S&Y`Zr z03atL26o~o7HpItaX}uF^wuiWuV1?n-FXGjsg)9Fc0^EkhKla${{H)|Z56JAjHP;D z*|~c)zI^!%3?Db=(6BJ4QO8~Us1di95=&LL4mGS>sQX-PY-p-pM@#2)w~_oq)g_sxh2R6Fq0a%ye_cfKvW| z52~(@v{Tssn+LhZ+3YPz@S9qjEf5y#%Ep4t=oCx zDoN0pMCX?50R;T`rLo@92Y0gY=cZq`6A~7*hhd)bNEt+0-6msDA&-xOmF_rty0=8P zBnzeCwrxyIOqNQh^WK_IaLx{5&w1pec5AZ@SO<63zTK5-sfy0SN(l{P%mhBuVJj=Q z@h3 zj_=$Y974UuwCI0;i@p4X0GF-wJSnzNLmz+f8Ms}YJ~)G4d@g`27%t}9hUV)ZPYLjS zSSWY7T#wu8+bYdzq?aUoi`}dpGizp@DV!7AUcQXW1#1UmTQQ|Axf%$AjZ!6-b-A~H{Zb(N#{{kJ}O^bXz6htleOxEj+Q za7od5(xN54Rfjx^yXc!It5Qt)6ET|7Ewov6&mEQ3Ees64pW``J4#l?&hpx>PtGO*Z zJtsds^OK&v;}FyRe*^5~H)ikKGNQhc@{sap3+2!K(WBfK)S3MdQJ1+^-waWz0`Q6&Y7)+n{3L+b)Lp;8ypnW#+wkIaj~`W?dp(-*QGYwZin4B=*L;_=>^<$QIgoY znBzsjWW=fE86yBAY5e3cs6iq1cM=k=qQ-lTRIwUK)7+5`eyHB>#mDb-wzRSu03+LN z1XLzoq%6jHqOg02<4B3bb*$K;ZBKgykuWhkXi>z0c!3?YmrYg{GGkpkK_e;FBM)v0 zYO=>s_!pv~w6gACBo`}&sa>K{$Ro_wgP%x~SSsTt%=_{f0d9GWy$0U#M7oP=9lH-S z9(+Snj8s;8J9?#7m@q9Rkzmp;>0ir{OK7d{8i;l2P)o#>D}yR0XovvHB9n%5T|;PP z-A3Sqa87V1=9IbZ#$f!GU%v})l~<`K4>o?SIt4mlf_3M)$zQr#4&LD&8wN4xo##JL zMIxj(0>$oy!>Ei{eryt!ycFStlSKv+oXiqiglwMdC1&N!aC~N_8eXgjqt@b0XlA=^Fh}AJ(^pJ+3#+sjB-^Xib>BLB$LtSSRyBj8air2w- zV?uqJ*hD})AlFCRSfH#&r}eqw9fE#$@v}$M(8Q<0K}IPSN!`qMw%azR|3K|f10uAi z^g%>b!CWOTSDHm0RtCgM;E)U6#VW9fn>`hsKdhkR;OA$>_)jVJ4LIB|N_d&R6D1R$ z^2MU&6%{mFX-{<+Ob%1s@# zw{N$14a;|Gfp$tnOgU75sgVBf=o-SBvG@B$^!dcJ8|N)C8->`CGqVI6^JOx1JfE&g zBnQCw4!K|ufhG97*d`eZk( z6QlKTu(HhBi9aea^cPTZQId!r7RaEFz=evJaC5S9tAJ^y)n>4MMf_l#7Rot!W*wEbYg7n9WUM=&SC~`}ERovuuMv#SfshLO}g~gA%xmGs%A! zE-RJ*+8M;X^SEdQy*%nD*)C&3z{E47{$m#)xzFofVbcc0WDi}Uj4Dc#Gw;d*u{J*S z(9_ZVSy;GgAK8G3;k`Zbm<%RBC3L45K`9TBrtr=Rua~R(@e9E@I-HFy zt^7!3)As*xDbDC)E3U(ba9oOQzQQe0Lj?p4D%+a&@_fjjEgMr6z&PMjO(mNH`lnwUFuio`FBj=rLHLaOY8 zqVC4tH%ZFz-bWLr+Yv}8?8m{gAp|y10ej8`_#d$UXLkYr$GaC4$4QW^l~Sd7+vCrp zc=#D3ldgfU+SJ?*EdjY|pAx+2=&<(taK(;cD*I^MsO#H{>7%Yr7fEKc5I0Ju+YJ6z3sA0e5J zLVNVn48FYZxUIrdf)N6z2k>VV~f1Z5w@ov)*YP4ev_42 z8ef=FzgI$@*>LFX3$Fda5?$A2Jwh@Q{N>b<0{lL^uzJ_fAJc2I%~3f+A36qRH{@1x zEi-s1&z!p6VT5_eo^4up{=p;Sz`xrm|E%xE9diebRX)6mGJMA(p|w6l z+>39YZ~J22R(S8XT;m_NP8NIQbVc9A&x7(OW6GWsXEnDC2V>&CwEX)`G8+$h`)3St z39lhF6-TCxYJE%KJ-5Y9=An3@t4zjywxcuYdo$O*9hQNoKf|eDjtbeZCqAU(j2MWu^wT34sHEVL zShis_7HTN>2gd1IoUo(2P5XdR*)kw9@2s$&5UlHI(k&#l@#6%2{S?b;It3*R`7!K& zt`7*+6I1WCaP9|WuoAd$S=ahK=eHsQ0NI7Al4kkL0Hy|yJir#+0@@aR)TgcmfFLaB zB--bCkuqH7$;vCuhJI@B$n_hxKfrF`?%)3usrT`h2xE$wjg7mX@fm22Reu*ReVz75 z>?JV=2I#HMP|>{v+`Svu{s;#Y$MR*&Q$KM)&!7q;%{ z!dInDz!Zqx{O9DzvjD^ZHi@4yltOs(J*0;%}U+2(;cY9R`Y@lKT76-^vIAMtmN(z z0TnKIiY2i1`iN31`=Mf9a@umQ=h$sMDozl4 zlF3)>hbRgNgx2zZz+Qp_0+mx&jys%0;jgIdei|kLHP|LsUYmd{?m1-GJ1_C?^^yV0 zez*m;2{nK_WQSj2xU^bOB_9#bgAJ`sO;`SDC(M4p7>-G(RW5_R^jePS_>fk{`}u&n z$*;gN6strhVBCu!bv5Kqtk}~g2t0+|5{*XV-t(=fgK(taYck9?a#Ye3ghc@ zcypO0;2-~ zPz&1v@51QpE>N*t1{km&a`{n^P+4bs?McmuUfLa`GGS0Zhe5gbpI?4?g(x3<>X1uqya++Kj4QKqc%C>^HCIVMNI?S1 zGl|raf&rM2qM~J17T_V+XdeId6L&OG36@JT!0;@2jU-5*92nyb7iX{l=l2x^S zz=&&?dhFG$*K)-Fa_$W)PfDv}KkA~_o}&{J?0CuDMJ^}s22$|#*Ph;BT^ELhJpG<@ z4VIUKc!cksWSN6LO&6AuND$ zPcTKM(4JT+p|V)2sI18}zUHL4As4V8iz*-QuX`C#ri_A@|8Z&8B%n1!bXHbCt~J4) zIYY(F-_E-NZq=oNz?;ENE+^UTs7FYE)z=v!0>_0(_JGlbthji%br$Bod@!6C8pR1P z0=+oZ^IMpBk$05-Av3vo9gJDYnpfG zG&TQA-&5=JhYKtp@5cvYDldeXzJnBwLPBci51%-8jIYNp5VK&-UsVKE-`D##8khQI z!9hduUkYm*E8mD;P*(TvuMRe~9M2K(rDaqN$PEDlYVFl~HLY=~iAu?6HLsd3e>-Y@ za8+jI#G!Q>E40&WzsPUS^!Z}y;Ggy{6jXTnXwZ%2ox3Sw8^c1Q^y`CjhI=~(H-+bC zFX}N<;*>m==@|TVwD=zF-{a2`B=pJd-zp=O%OGg(K&`#g1@-JC<{%lys{?w>p4lN1 z`=)iXr!-20?3^0Jj0?o%K3gZpUG!OTlLv)^eWQRR#lV-#W5CX0Wail3xQkV~$|^xG zBT?T7GaHBLsj-X+vakFfy1oOP>%VPVQPD(2DH+MAWTmoaLu5D1L?Mx~vXv+;Lc=d1 zl0C9%AQjok9);|kE%QA;{`dcW-sgCa_c@OHIiBaP`2D`)b6wYYp4WMHwK~393}F8U zhN6#JA40`YBg4X^Rhk$3HE?Uc09}C{X;-3J!Wn7E2S0P?YmKj3l)jopS(dcWZ{FD)d%~YH)xuD9 z=&dGlTZip&auU>GWlJx!+vF(#2yLC6UlJjuq(*ts^{{ntSPfk1YT}^i<1mw`mx~IQ zJ8M1w$WIuGJcyME=e}UtOa5F+b!6B6iT!#F7W@?Fqln+ED8ab#9j4cRqkqGq`#MJ5 zle%0s;dm~gt^E372uo-}oSX$H)nw#k;@5p5mdSbaejAFry~XJ`?Rz?FsNRy!-<}jx zI~yD0W%={c!iWcFb>vBmBVPEHWlnqbGWQo274>{?#G2Q;_wPjpwj9vkhSw~nIxsI! z=IjRTBz+xoU)T?)$a}9{2ExEPtecEfBcaa(8ykgyq#eb@rWZwMmz0zgWjVeX=##iq z2C55834<~aUVRf?iK<~{~Y`BV~hKZ$P$ZH8M8fGYxFP)nHdV3NzUY+Om@RDTI; z0PV2-sg5OSH0VDsVY`D6j7gzHnhm!lBZ6eFCmmlHpAZXHKGu=~@6-fI)sy`?n$Z=Y zAB~``RS`!X2Z>$o>W@VOi^E+yetfhQ(|Pebi;`upYXI?7k8c2`)-6Cu_MWW*cR0R* z*d_3_Vu)IU={!UnllfhEK2yp5a*ET-IshE-@L4tTqhS^r<%}NaY|>dHurBMNU%vRG zuPI|62}6~M)qaArvjC*V1}dkXX1o_u!#}SW6}gh;nP3zBHjuZfN2zRA(Br0R!|x}2 zHr2QH2Y{9sLEN$0q3k#YTMi*1|JVcQDmKEs18I8FwEx0K5k{{Y{wIr?o(kdX|0(YB z7rsdQB;3YVb#qPbad=)sfNs_9W8{)+z2WwNllNgK##H<0dB8@UO=rf^+6|4IWK@J zLS66Cn-c?u_D~f{TbmIfA*t9$I_{$a(X3w0(jX)OPy}8Q#oDa_Ty9fAu>ufc5L6Mj zF&ECxMu>&av4Eh4MqWXIGJ6XEfM-}(*q@n+uh^9`!sWzY3jnQ(lYn}vJ$uS^d*$R?f7+<%}Xs`dA^i%V#F*}GJhr)3>GA9r=rgQ zXvsEp>8pBuS2YBNi6*e#3d+DT!6?4Bm>rnnn;7d&mcwPxw+~EUNk!Ps*4CtsJW7xo z>Mh>Uc#LnFa5|pJ))LB7;$yGS%uRnCb{)OHe;0fhVmV~#1FL+7{>uQS%|Y=G#uBR& z{EtEp^gri-TA7AGz}|t=!MhM9-v^Q!Z^vQ$V>dfd%?#~hK8_s0o@T^_pG`P-!MwQ) z0+&Z(hp6&_N7GJ@FD|%P0#r+5Lr=RC9FZ!a)UTIwBBaNZmwd}>YirF-pPja(w}Dw`!nqXAvph4S zMtS&b{E2uyBe<0Gk#2nfz(=|rIP6FG`Q206I1I?j-?KAlUbF2jt*nG#)}?HQLrcGU zo5#F%{Fx0{qup!T(RU9Ras>1cH!QKmxGGia^kzK9ML318S-(eHU<|_tdIE0AK{|9* z4OY+Jf6xKvLknp;fJ1^2uhRjpOpHT2u-1|-^;F>eE}ZZDUjq4v8hc`VyrUuu&Id_` zADg~UjYk63X-PptbpeNyx0A*9S1Yo3@RQM-q(7^G=Al58>hBD;T2~XRgmbR5ZMYTZ zK=lnbU_pnR zLjp)z2BbU|cft!KebM3X*d^HtrH}LsSa^z@%tE&b6Hu|NXmsQ9Dk=}_$iP;V7>EdY z5c0Yn>o#ogMvTSPM39*B7*nR5PX-_u1uK;8h9j6y{($XX;Y)i4efpEv-8y*eSYUX( z_S%GXIW`%uC*MF%oVZl<^(zVY%CUzXG0B8-kzz8;bKd~TGCrF7{5BHIb41t*oiquc zZyEULG+xL%8-;$*CK>!E8CX`f%qveVJod(7H6)Nxb*-Isu6W^Kd~hu}nhQtwHFToG zllYQtpgC6Vj145SAjtk{XMEUOXz5q@PLM&1m^8S2#CM|IuwiT4i9Fszw0~#zN9|1A zbi{EDJLfkw$>qdEcE#{Yz%fh+gbdnr&dH`#T)`0pQS z?2cmcYa&0jBoE*cq30yO)A9JTHhDZ^MmrW!xbn5HI({Jfr>XN93V#WcHY)pe%(-BiTwZjXK4ENFM~vSI=v_N%54*^hI)Bi)Fo4TmPFU*wLR z;(ZX?bKYt+w%jk)r2njD?K!nsNz*tXX?Zafx4B?DS9$ll25UereS(96&C&!0k)NYD zfpvB9;2guuc7RaAnBsKBNysXC9~&1I7(L{=FX$>?fgW`^E!DBRY&(Al%7qEmda^S> z>3W0XFots#th&E0PdRn40D;3|X!sDbIU;C%2}$(J0eWU)xXTe*5hmjYApZAqcGZ8*cD{hHMPb|8XZ7eJn=FK#mzuw>2T%D@>GJ^R3cZCQT> z*Cwa;8J3NVIDql-?vI22Acp$3q2Nv;XIFeX4n5ah+(Of&5wt`dXfrlg1=G=KG`bXn zIe5N9l-Aq09sIkhoZ zMDq#D8`B>)H*P?Hu!d+%aG&rmgPNL}n{Pu)ZFHPwpTPNCFZdBeW#SlFgNXzL6rSeQ zIJQ-+l%MWhh0ay{IfONz@fcI=FomNVdDQq?A7@xQZjy?XfxdqJ*RMz4?ylw*5@H}N zT2oVFqZ}^(Dv2Kgv*M=x4{*82sjH=j*#ab5g}oC*Q#i^5FC#1XCKfTY^hAkSJT~4; zEd~&Af}YKjH8v3XbOq!GXz4*2&#N8t*y#9d2hLSuhZ_YvCle|%z6O7HbywiJ|L}o| zpN^$4S7RLfr=_E!+W%y+0ONXh(K={?Y~3cnaUEAU$EOjYm{f|L{vhjisIqUtuV{Rl zxh!G3s|-zDs5xJ~(w z;_`^d?CtOFUX8&6FxKs+S#CEJmCy4hLNO|G`gO*pSYyR@>oWd^Yie|X<66wC&UL(C z6~97+DfICiHB|$2nfhkVIj||IS9ZwxNVAK9;TDOCNsGoo@f)`tK$JD^RvVsB$~fAM zulncH(c-LlwqGYetQA>&pK#%Lccw_<=93M!GSNR+Chb58U2he94>x}v`%6Jr@&Rz- zk|b;GL(B{52=%EkEfk@`ff7894T$m5Fshr_dCA8F&|d>wqP;(uM6kDyB2V*4V<6B% z()-1P;dw<_+nvT^Z9PIh3r>e;$jlXMMYDkAu)PO!@u^>f`%R06gzwd{*TvzRSy>;E z1IE^XIN;56(%`xOf^k#Gg~YNJe)&Q>87Tk@k!xt1fMg8}1P^@S17dX?2zxgR&Rk;Q zP4{R7bdA27))eJiO&K}3(DRkqi(##k7~J*u0l7UFtGTD!H5uSZgJl34&eo0M;^M+C zO_)}p$67o78tBH}y=#je?I_Y`XOn@h`V%TQecr+;CQr2LMxL7UFQXatEOs0{miRfzS_zm6u;C0^#hqoJ+Fc2%5 z33`E1oGS9hAo8}4KeW=3fffS6<>nlr6Gu;;TuY#MiA=QFo!ipSe{|joX-H9G2F%(+ zZfm$*!&Z_V@;ayNAfYE2;XnaXFz2+S5XA$Qu)G}K0quq6FtE8A&8wF|K7tj6nFI`9 zHdo_R^`4Q_zD92Y(e7}*j5~qT-gLFMb0`=6{Ar-vv$T-6#G`yXfNLK45Km8r>?h|| zvg5@in9!b8j)(3&b)3stJM26dR&EJ3JXd!zmvj!YJQ{4kmc;8fUu~m#T{ifLbNpb& zbQJjWF#ox?vdXZcP=4l1fVP>=x|>McqnAOamMR6*&_G$af^M|v-4^>XjrcSCjiITj zh*)V9M;pt;*o-oA$y&lcPb^{+TE zY;LbJXKK3F$R-eU(n2MHPvI~3*zMfa5i~oVocCm}@e%qEq`DA23e4`ywzZTs8AY!h zJiOi&8_H;79dw>?)~jDhM@6i)@S3N!#oxmjj?#D08ftW`)|m6XZ1I=-$oGg+p^2QOxWqi| zU^avAqs5Lw{|Ww<4^x-A=RKxOU-$q5v%+h)zC0PX?kCDG(TFL>nqfcn!xO4Lsp{@@ zZt|0n`=ef3n;iLcIAcCoS2B#KxMD`VP7Fyz#Y*i~;$8P~bHsj&5jENlPke{o5PD#W z|9&F+7YCk@9CND2=hw{coMDc1{&6;!&QpDt=-^$80fE}?;QU>zu9f5ri3Nj{K`r{7lc3Z@aknI z)U^VRq9TZ(E?(s|A|m!nyG%hmy&~xG02wYp%yH-YyB8ad?}wsZ4y;xBVmE#ugoQ1- zFt#H$ub?S=!C6^f+NL2i|HD^xGbrp=AB?<_;=U>6kN#EYr@g3{wZ9 zotQ@^y|r8Seo5@==~)A9Y0E$g7J%2GH)t8CO)}6MSOSsK*46cOS<(rQ(=J25RnSqu z#jwpV8410B8M*TP7a!}5zpJn!oafZkREsV`+UXr4$SAHO8usx(txt4xbixaq%pVeL zcQ9e%!KWlSqp*y?40;=CvuS4>K8$VMOOK}&2FA#W8L$S*U$ft6RlHGf>SAl*;E)s+ zGSZOpS~Ublu0}v2H3GP}L!dNj2V6vonIy);f!g?@mm`1(+u`95qjd$EQ*&$U%9kU^ zsv(0g$Vd|i?g_Eb-GEm2HWWm{nAXuW5T!)fq!pa4(C+?-ovc=q3CD`KtrULu8a5gC zglk{Mj=0SpoSm5wte1u2>N+4R@eJ^hg)gm@0?0;|yh$lc9I(;`4AIo|4vY`z`}+tH z0ra3-QcPSNkzkwcEx?)4Az*~k5f9IZV~UEjk4CVmBZnJgs)q0O1)eH9#i2zj!2K8Q zfg#K~GNK>Mgz9v^PAZqJC4kjWs4R3SZh&@DQ1HtB0_iQpIp1HyBISG#v4q0Y)Wqn1_h9VFmvQZ&8}v6fX=LoARS zn7?jGQMkupCBXZyKmF5>%=R;XE+8d~xtjkinq66#N!uDo!{UM)lamoHNX`MIV2F@( z5H(h+o3IvCz!eT^io1nYIv)ojWlTFxn=eSn}4WU^{SM6hB znuDmpP%{aofLBOW5H$b(O27Hf#3?8-HanbN+zqW7v33)3N=;Aq!Vt1{l6qNmhthCq zOdBt9kd%u`k^f za$HL|fZbxatE|Zc_eWH9PJWJzu zI?hHBKiNf9hckGHRuLa~3!+jYM%+fnsi6DyB1+Ci)c~$-GVYQD6VHx@Fj^LfKwvbc zFomj!qux#$|EqKUxoL3oKW#M41)J~eUjHRMQ`BeIEzOUsVg16`0vM|vbPzRW&(my{ zHIMwn-hx&9OXPU@;nvPsX<2RU8nf05#rNS;2r5aUz6(#FHz;wD%W#1gt2f($ppI3L zHedX~rxmrHByhzh>SCFPb$p8pak0WO_*#cZY3ShE0BM_8=%t%$fHY7}Dn+ebhdI>f z2MYeP1|88wKY($vFuBlbCPpXqJxQ3;OXm+Lf_x^)Pi9l*=6GRAdXupQ3BQ1!)s3W5 zB*v}4_t_L!2m8Bl^l&OxwM4>q0gb(i6?E7ekU$xT>xl0~=yQ{}}$C6L*e8a<|Va_<9i?FmMh)*(_Ap243ccI~0c z2P1#6eCIc1d{7QPzI(Al)_Du7Q3HOL(YkaQtihP70OGovvi&|2|9$w<)WqUO^C#)- z0%91eWrhmC}WB&i14eCv%NXoe>yCohGr`h?^Xkf@Q>)ew=UU}`K&V|H|LF+uA2 zMzZdlpdP-`))HeAa`nEY9NnOdiH;%SX2SR8M(7IQoTZX zex|CfPCs7kUgrk~lU))HD$4PS_ZVBmK|*2G-;6&5bjr1@R&}>4aTR3xh(G(!pFeZ# zp?W469?rjJXQOfF^tFue20J-zZ+d5pF)T@LsmKp=44B-r%B1F$Ri83lz&1Wz@Wu?h z#bsI>OcxxToh|wx@MBt*%XRej`Yd;e!P4y%)ya;cmKMpPnol@~x>&s2=ZO*y5Qk(ccss&R zQn>!;j|4XaT$Xw{&SF}73zD!U6pOE;IJg*pD?NI+ONzzH(lUFzb9mSn0%Lhe`=O?% z0}(%q)bIp&A-76dLhN619TZpj;{J zg>(ju9fEe@a)0_Dg2)t{ooAz$t{h|`W+++21rdodfI1J7F&vN7@FW`JRoNN>HvPiT zTwo52pUi?$9yAc`b@v}W^unkUF ze&d!%XJItb7A|xx+XOTAxp<}a+P0}pD#ozJSp!k&`nSvGl=+??#(M&MIgK2zAL-|J zTlgIxTBNmqU3i?GuH)#?B&L85LAsI_ck%54J+;ZA&Dd@H`qcAcTDPMU*dr{}EFO42 z_G__L^n`~BY|#o3mY4H z)S2B`(GZ9~gkdNkUZRA>U$E(Uk0yoqf`6&TVR;>>TT3W0d_iZp}=$|LJA@5g6Nc;=$gB~E2*k>zdJ$J&Gqq4+rT!hn+5P$#Uu?kjLY;g zEWW-Rfx6$s!XmDqs${pQ=vGj9pI$}-4KQ|f{h+P}$rS;(yWc?}%#L???c~K0qG3nf z0$$bH$_8bGxARFQrB9&3g63YB!5?>wyL)TQ^RcVBCiZ|>Na*MVmZ`b7>= zWiCwHE>JDHXdDw*xXGen;t_jj!pZeVG=wh78J_~J-}vs+eqQ(>*H>+JD)QOvng6@( zi)u4u;r|KI=bk#ikOjYEU{E7*XNHpYP$~%Ezn{v~13ScLt!R z0xZ`h9sk^nO+=kuRwhQu&@&KL55L{58pWAISU5l_nhc>Bv*$l5szRf?3Nlqd*9HdR z*l)@?&?9CIdQvXQk5&q??IgVUuXhk0O2Tj2Sq#Bobl~d*rbNE&amBEUP_nym!_1w*3M_rllP6p?)i_Ub z3{_gQufJB8@$ms4D{n|5XCwPw791>E{oi-l$)lC`u+zh1o`m^;O=k? z-7=m#Ff!uz>C-29slGsR2u+^C;fak1X#EJgk*11a?~!rg(TuUNF6EuV7iq^+NF)sj zY7EguhuaZ%3(^*Y=nCAO6dWIAB+a^1sQK4%_qn0N2h`A|XQ_su4=1q>6#HC6GlqyX zqg1!UNP;px)ZKjtBi+T87h_-yNaz>Tvt;#Wdm`}*0klx&1jB)?kgCx>IF%xI0t?6E z`?YmspmP?BI=ujS1OYj@$KjHpwO2pzH+VcmpOk+bf>ws#GU_fQJ%W{g9#5tr!2uft z4A?Y3dsPOVb2gTN_{j$oV(UzEhv54mX1{{pBBxOcMlqSUSKL5TEyTt31%%s0sA*bK zh;1zngxEK>h($@jE=5+I55w+s)0Ojo+NrTf6{r`D^%h5LB5{b_$G0JTqYIHa5T*(1 znW+~(uO4oII1E7o<+|eFv&zz5Us_ndO_1vR2geJqdLKOpshzaqTag+j<574I=;~r@ zQ2>>5H4(Cc%YAq|s4!ZWBvxbXW+yd+ShcYP00t<))9oPi^#pD8yAE=Rpt_?0=%Ua# zZpKG{1;+fvB)Y3+bR|1($Ak2 zp24s$ShM}b7Cao!o?27UPt8#!RR6lPgvD4=U&_uhr+T0>Pkti;|H1BkyBcL8l9_@z z!OJ}j8NqS|6o#SSe%^rkIKz%x^(C)_8Y<;dv=or0sKqYu1Iz}&cmF@kq_|ZcPc})upUg7Havt3n(S&3Y7 z>$VFe=eIBzh{m5SCYKKUK0cD2$?}p2fq)gmzJEIn z+MXc4UKhgm#bN^^&RU)e-QRs?fsKTHn05vliD7G1VxIkQ3&o}AF&7=k?1F^_8d=~b z;Rq0D8L%I0V1QC_)sdCv&6)gQ)2=1gV`*r`)x|fs`7HzRTaw2ylCjv`(Ls&jyR$A1 z_!v?*ZIaJ^hiU3v5@MWu7EDZxc5DFE@!6xQ<;{Ra(MV)0qHKur0(Zzd15zH$WmokQ zwgDh{+SnqFQUvydq zKpn*(#O0%>Z+v`w!i!`~+HhMkUq}RDfu!WGiBA`@2^5OS9N9j=XM zUZ%YcUMksw#f+Sdch+y`tl7l*`$nrDV0jBLFKF|%Xk8d(% zMH0MzrX}UO zMI1}OMmXvy;|6$)S~9LYS0(03HmqSlZlkn%01)(!j8rCQ0XF$(O@j;r@du(+C=ps_ z5Fwlcq-`343)zOg-li8bsWB9FLBzt~8v5sldRn`XB3y_bP}pTJW`;#rc8X^Zi*AzB zTt_ylk%KFD&XO@Ab<-DOpFvh@Yslh>SQ4YzB`lPbwa#IP7S7sZmj;g{?0A}3NC5f- z98Nz-OLBlz{4@b&;v}NR6dvwQ_D)gV%Z6mmKF1U84VM+cKA0Ud0rXjJS;#^L9y*h_ zXtWbu*(VcB!Bs9}g;vOWGx`y!1UMBAga`!;QX=$~dWOa6kWzFqbddQQm)ro{cL7_c zDyWe&>n;ehpm!cL=(Y66dv^GfO#; zI_*R_LTn;bg?6x{sPB^OIRzAt2H0;%DK5bZOftqlkhXmx=?06Ire+(qn5vWw;FrDw zdD4}gv3SLDepQit2?XNYQYwO@@EvSwVwn{V_-rJ6`Ux0a>t3`^Nu|vEx-?+Bmw{EZ z33q`A+d6g0{ja6q*RKDwnbG0Bn&Us_AwQJZ^s*h+U`5KrT8ww`5P z;hldD!L3He`!B+*<1G{=%O4Hr5P=DM^>;Bbt%gMQ!mKJY^YhBw+uyOlh(%k|ALwRI zCy8uCIVEpt!Ti+*xV?Ou2>E*f$Dp7|;rggdXm##_wTBMFUf80B7iPaFLWj7NJRz8o zgHmYAwKr@~1TB36pdQqpiUE+Y82Zn_D(}Fu##oI%7kP|VKq8}Wm_t8RA|Xe6$83ss zCQoO-U5aTrOL|Jmg}M}M_2LTd-I>_l2g7UKbLRwmia&i?p&&0$sD5IFY-MFdF-c2J zEwNwC=azDauc(IDw|@KNj-DJK>{FR`0~I^_zfvaaIgt_6fb9StRX=@ zBIE+t{R_t3a7?UdK9NvLh` z!JzW$_HF93%Uu76SVajURI>Yj0NI2Pnzts9Wji>3$d^HxR8(x-#43K#yjg{a!H0&1 z@Zn)QjJTA6nz8BWo);X6s8*!y-hYV2i~iWXurRi9)hBK}WhKDyavZ)TtoZ`Ah5Bf+ zB(}lO>_8RznY5dV6cCw2E2*gjj2AduKt-|<;+w50HceC*I*MR;?16{)d+=-d$W1lHeQ$J|LyUyC18X!H@;TjzvU%k{Xu$^ zCY3@)wY_Q4EXQR!Y4F{$%#x{6?c&ACKwi+71-x>Uz;~CORb`5piMD@Eo1-=CaJ`B!}yBh-43 zUKFyy<=^#?(anBm&5D!@{b%pjo>?k-g>AtDmPcd-TNwf_Sb5!&$OK8*0hOVbBYccMV=MH!i*D*gCEs_DnDo?*PE34( zVumB-@?&bGFWA_mJ|ifrL*EZ?J)<{S>;BaO44{5OL-tqc`0J`R$XBxM;Y;XMdx?C!##Ha- zYRs2w^$j+%9Q2V;1M>AvtYzOd@yq=8lNY2|%lV%sqZl_qZ8zMXHsG+ou( z&-u`gAP!0}a+UmQP{?SDE}8GZ9w{w8H@gxeyK&MIa(+Jno)Iy%ThH56k z7d8ht)3EuL6mTK#4s?{C(0fp^k4OT5I#1X$Y(o%ZaKQ@&UXmRvsZc@$U;v?-9gTd= z6@($fGJy-SlLgW2gop#!B^w0XtmwW#u5I1b43ub@2l|_&9m`1kcCw=}1mD)W{)Jq;>`3=ZUoayu5^dB+a zw+-vwa?OCzo^0_Ip;{Ct;Ky{auO>E}XH{|az&fs_HH zP-vP;wbc-1L?Yi>s&Tw??BD+|Hk2T2xP;8Zm(iio@7PglCkf+RDr_^hhiZE#Wz|lW zgDd(&pa}QfVbX=ueT`{6(oSQF_fiWmD81rRD*HteE46yi1U0~6%rpdz53xS>E_6Vk z5Lv}ci-uOd4b{xcS#vl1@MR$$?Up24-vy?rf4>po{^{o^!?m%tSW~cstUpZ^a|rQ( zH@CF7h>oPUySwn^GFT1fI(!^hB63#`k7vf?ApX z!aXP8iv{;M)et;PCfETF$`E%L^Z~A49*)hX`!e%?sBLpmrir9N-Zt_WR$hJ3pk1mU z_i_|xm%P*h92pvrpJ1v6w5tePBH^KAvlC?8rKuX?CW4e9bu~gXJ3BiIyUgN+BlfN_ zJQ*CW^o%=pJRtk{;=I*Gjxta!V|QFoQc~aCfNbT$V<~%qKn}`0TwFBJn^YuL<2R9P zkH-y!P87XT*Et3H6>_PY7Gzb6khQ~-Bt9f48ZgjybnDqtrvNu76W1{G$_iIQa6YwT z6!C&l0UjeyIA(NKR6|9V5#{{I$A_FewgZ4Ee#2lhU6L5>ts_#LNj!_|N~_Tv?J>dIF@Mz)Bft@w&-zOFQ|iEPR{V6v1@gZignH7BMj zed@=4VK`NZfsePNxJ@VR860QLT4)Z1_U~8mA)ZX(@I1_lMw{9>!?!}~STDOG>I3vj z*@suUJzPpdGDP^e^|=1%w^=05)&FSBvFcH7g_>&qcvJdL6FWLOkoxm=e(8I-&i zfnzFl{)mLcmA(X|&^uV7`D28&1Qu%V4oV`x!tDnHsqZX92AK~z4l`a{z;m%*4PXI;L9LRen*{=-Y@KO0_2 zGDP|A^=Q2Qp$1jkdyeLE8KVBl~e;5tYt+7)V?@pcXINHm85yL5G7v0k| z>sDz*ksnOo;HfS(`(d;E9}|nSnsgED#7u(|sYF%giY1%(-p!PedmCPfT{aoJJx|#m zGHVS8z=5oi116C;nL9Up+-!XAKT8?Du|s*Q`U+N)_Kbb~`v2=s$hyXI*Y-dE^=+19 z{mpm0@uA2}6)xW^d*$V~pI4!rlbm?U19!H}kaN9W)qi3V3v0W1^1$lJ%-kpM|GE`M@Mhr(Y`(a1G@FF9>}gY2?q+Sj=6kzk}M#Cb7E{e5dz62GxSS^ zAAjK*t;+uU9Bn_=OFIn(-bdqNT#rMA zS0loU>iANfE4248_<*|uba-^vA~h8kcS?MlFLs^{s^G!4{W-TpgZE5i#ncsiC77$a zSX+csB2QQ}O%==Af(SJ_r|;uKqQPLv4q&GEEZIFlhd^`bBp!h#;j`ef%4nrcXUVEF(%i+=D#20BNciO_xu|E}M#>g~kE@}dLFS1CcB95ZrjjecLk7T~BOqlyp;TL&etzu zcCpBVyEy8s=1niI3FGzQUSr2J1li^IG-OZSJj~0!_vi$xzab|R{i#0>Zk~AhDkeo; zTQ>HXvr^=9b;k8{kAEF%{5d}NS#9Q0;D($u%6kbBvC1M7h?{O~N_LqVF8jrwhDJVd zlrgrVJ)Q#dguv!7N-i3EbP5Nju2yA8d69w|>h^Mv#4$o|`RMqwv=8=N& z(3bNi1_%Z0C{)|OM>7SVy0(|JZRpuZW^mL1<)ktFdyPw{ws=ITT-yk*5TKlx3nP8 zfQC`xe%$E~yHgRcbLIGjZhT>uM^Sa3|A&%)*@e{^m)J^2q-O`7s zZt{e=28dPTxw(>%W>U1VTl%!rocd|-MP(E~Z=CkZDvan+OH26!l=cG0EHhC)aUv$A z0yt36oOUy%7h1I-krekH1uHx8kc)Fsuu~AM#~N1FciJ~8a3}1RuATb%0J}jV(|zz{ zOG>FgnF`FU4MK+?Usl9fII!NCsz z7e|Ot#PtGO5EW`-XP4fa;)djonCiY=bwbTa0V^;@QAG=-D2q9qx7Xgt>W)juS-m*`0EV@Wd9~2ZT8BVLybj zUlPPU+1qddvL6mTJ!>c=Q4KVWt&TvRY8swe;rIYt(jxhZYh-AF?=cRIYU6QjzZ2z|vi*xeFGd$9`#VS^a ziqvk7osEntx~~~jE%9cexk;=mJ5O7LZ?1g6UHGZSDO@BD#d61mP~RstA2vUs?ib)F zIqG+QAYySJGrv4-itwI6*{5&SHH9;#&kl9$p4?GxP5$gTClklPPd+dBF1XiU3Xk*} z)d^-~im(r#8ugz&Rr2@LobqVzOr-w(b6#*njiE4h-!O2fIk_z?qw3aU`jxVC%C_G} zm2&?1_#&CUqd@6^x`-F`xlO`k(Ym>Tv8-3 zB=GfhN1zR#F@wAhW6=BE3C$ZE>$Em0hgJv#Qe#TvqRY%p79hy#t3#sAYyz?_{3swh@L9aMx> zO}Fl^U%!F^13zbw)e~OYmA2e4RY-D*Jm@xSm!6enwksHW)}Z{8b(i&@@Xb#mIffvw zgef@V)?!czxoxGja8sMhz~Eq0A=VK7>q(+oA$@N9Q)6nKz_^o z&O;dhT^#q7ql${exY;&u$3b&Ah*4_$VP(bov!x|yZm6y8CK{LSpL-jjaFQd_p*tF| zcF-(m5>=g@#SfW(znRktu(l!3!z0NA+B$g*sNE_c=4bljlWoemwyyARWi*Vd)dOy! z0=g(bcPkE8j(z*qAs}N<;uR&dE6{)_pqqT2J`6}6M)JA=x;XsEB7qXW77njXt8a(b z4bXW~0V`V#HSAR#Dv~e$3hcZ}@ZJi^>m?19}F;ZGw{b z42pG*PS_`HL$%7NW1v~V2ty{2Z1@7O+Wav6%SD9}>M|?_eKJ0()@$fgNs3;DovSWP zCf08k{?ge6yOf*gZ$u^~&~6b&sI;;Bq)a)&IF3?Shkh131^={)rur97t^)@)0zo>U z#7}h-g&#I10-$eJ5T!s$0irdj5yMUU__0NinmoDi@Y$RO)Er&{fEvI5~J=ymJBZo2`2~OV#O+7-} zO#f5uy=qP}uqi(4H!(n|p=W01QR2_}i*~*(HagT35BHVhDKQvHS8}qrscO>G0Nj>Y zvAE#tjg72gaWe4Zm&Y^>?OoE*c#k%fbo+t!c<|WV9qMa_;=3U<_t#F0K>`F5ROvs= z*Nb+I|M_zhGLc{xZay-3qq?yzevBH$gLe|}aq4d>w==C2d@pl%Wc z4TE;P$=>r{?X4_T@^(#>3gqR(d^V^XE#}8?sQS1EwlFajpd?s?v{Io*S&iP{Y)3<6 zz_Vwo$?_$YY+}ba1}L&5Uik#}1bEdlel1Z^;r>4xG>P}KK|2cA>U^T#c{zF#RP?9C zwvv)yBDihWu=Tk@vwLpUfN+e!Xyc#x&|-8J#3}^KawG6h-lm^5pn>l|yWFYgWU$OKjT;KeLDo<9wf2?O^Uxy+g3pdE)Ya7a=qyUKP*Uz#f?NZP zq$9KsugsdnGo^N}FN921w~qV}c6It`nLl%CvFT83g|9u-=EcIr*8yU_98!90~jP?u|P`h-1P}cS{rS770M7OkarG$Hh2_A>Am@9%qO*L}O*d z(_s|gOl?0p7qX&h>j%q?>)>JR-)QvL4${Kaf>y!zx44$7V8q>1l&bM>LHr! zbib1hgyOgj#C45yr}a*=ebGeuIr{5hC5Cbmca+nLlkE#G z@3nHMu_ZOk7#{fg9>d+@-^4Gkon9M#^c#e>iXjD=#y|^$G?v!_uo=`V4%Mn-hL)c% zi|9!^R#X<8lI=fs-~M!y>^p@NS66RFl6Tpjz$Z+g$okX0w>Bda^-({vI7|RN9j(8N zshC7t9$U_Kan-FTX$C~_s2|7b@fyCEeVNcO{YDS|(Bk9F?*|bBN5sTx9p5I$M?`Rv zsLoy$Xic(V9dm$!NpG~6Cu~ayAB1jj9V**XjftArK}U#4O*NR*8gl)GnfktWz9@S2 z)vlm7PS|myYJnBZ)d&&Domok}a;0KDnOKSAt9~I9fI^f^{Bv-QG4%s%b;Q6Hf(ZjV zLYkkE;2*>7q^PABI_(g%iPRuc2BEwon#)mKcmlT9I`_SKu2_%coP78vRmW7|e}Dc! z75ibbw~87&aQjC;E+u1q9EWXqFH9+>z9MY&00k1Y@(+SZA2#fg3_`!J>lI@Wul9i6 z*1?l%Y1g@Z&F}2vb?i~=QTTV%+FZXb6b~#fF?-^hkzkNp9{yWDWp2L7;h)Weqo*@* zKxji!dd`km;msmtHLoTX)3>M2**TT2YwGho@ogoO2OjdJh{H z+D;(TmgF4!xL-*J zoFW^}kL50P9m?x`7M)acD6}c&(uZ?>X*2eTpEnN){0(S!?WP<@znJrr!|Q~S@3@|? zgsEtJ)5yDt^Sy*t34E2(n)%5u_?WwIpvH;JsRkHW7TfZ z#(H!f$?I}#(-6tZJ>`aaJ+vE6JS z!hPS}raAts(PDl-zKNOBq$j4;5E08|RM`JOy2H}I!e!4>`dHoz?$wK$Ip%11pB7(V z6X!+k_abINq{1dT^Q`9O#pS;cZVRBAv3@(WpkRs`W9q;<9Id}@V^aWV`FfxKC!>5M zEGpVXflHqUr%6N|tH^oI{8o_T;iD7RMyC6vT_hbTuuGT%YY zE;Hxf_jvx`Y|$6~iM95khue3H69gV5;=!>DqNlI2hj%Q|yXzO;@yRy+y0(fkzp!u} z5XUil)Iik9iij&8=Z4=0J%3(w$ue(YYRBqTtL{~#yZ*H~xNZHW0aU!UE6E?Z->Jg; z0-;e&yw&I{9$EKd;Atsd6eJvdNE zFZCcv6g1}5JXQF#xUWYVOvq)x;Vq$uhZ<7IO1`3*ZjQ~K=aN1?>9iVEXTq2`e3piL zvAq*yL+g1Hehor8J+Kcrf)t!?p`);r!P|MNju^GC^~`@s5=cnRgvjnbEX<55I){cH zVbikhh0;lIJImW??jGo6&q4K)s0w^YHAwK5(?-ehG7-3ZI0aIWdcbF~5!R(I5^SO9 z-U-2!Z4{2_-@sYpY5^H!1D)wsLDPd$ZIC4q#e{Zl20_5Ex!1`CQycFa9HC8~;dGSK;_QF`3Huvw zKtOE{|HFM`y^6Szz3h7+kSiOaTEy>n^V^6UTkNmy`|$k-t5=_+YHG@T(4yvqr{Wk$ zSuv2Vv9=+2LgfLp(A=ZK-}_WttQ4Rd1wF?WdIlP~n8`{cAz|(i(c!@+6y6L<|G}#* z+dOaIK1J{t;3LZu=qxCg!eN45qYmz z9EqnSkCQ6K&G5{{-y~ymd9jrTF=2@0MRNxoBB4y8++CcV<$+x0n4MHpy9+r$l(Z;5 zI7BWMsT(;9NPpb(X3x~AH*em+{h{&h-(63OpwGcGa3#SWPZlpPuMx|6{D4nzN1mGj zqqIRc@mXvu#-cU&Wr6mP7jcM+K8+>nWn%AJsDfLxiYl{%Fj=1p3n!wd$m%I>m&(Fb ztZUM&Ui~8Bb#-4GTsH#BO0lMqk4Gh_A8pP~%rX`Ud!FLL@g6u}%5okwG;)D#EA7CN ze($qWaqCAiAqvUrlKF)~=@Bh$r}lj@`4CR|;Wdc?1+0(2ne z|Lz`z`q_);#@#pHbiYxJrp7a6XYi(6nQ!_O^b|&d+kz~BCBV&B)QPfl`jo}-qS2<} z>#baLn_D;guNQQei^+VH8&r{5nTM+pIk-$XjWQ-HZLFF zCp0YG3&kRplAjk!%Yo(1fL#@tCebX=&6bJ=Y6EZFb>y0Cjgknlb_vXYG|&%^mPzhN zE+RGp&#$bv$bjRSX}1dA?Jr1m{gtShfxDn_9=_LVSq>K;?BnPE3PeS}rkD=@*m|fz zzo|c$H;!TzyC#Sq!B5B0<71MS3A)h&F7XktO z3(0+~u|F}{DNZ++mc|!~Y>v|aP)iJV?ZR*{-^CJtro+50fvQHl-daswW?+Q9)tOz; z^dE5QL?d{lvjYYtdjel{b$0`q#JP37*bXp7 zT2Gh6$cN8+w6Mb1=%$Ma?751ja8@gn%ffNvP(}nB#E-?+RRfY7p2o0h**6u7H6@|= zR`l@M8L#!qAZjZQeGbqui+r-*5Bj`LtQu%k+E~UaP$^B(3Z7m#)7tHwQEX-SJ5S)7 z&Y**FzuuU`Gp<_qR|lp;w!pXZ{jBe7`LXhjH^2uXSz=X_Px&y$EVmy|i%01wFXs4< z*H}*iaq!~a$?drK2;E-9-YE!RmRn3h32dTmxR<7+bnoPh48(+0Hrs zB5wwcldpW1QHO#DO~2^KKPLa3EZ)Ntq7z^4*YH?i*>P9iv!e@{`}?z;d2wMk`_h*N zbZp)y@2Js?ok#b1%{;nEJt;g&;dIA+E)|c;s?iGIpDC8dw0ilf^@o#R@FlfZR;_>Y zi;)sJHCZ|-!`jK%6%L8z&L33R`-Mn@-c16+ z=Rc#P?T=~mA$`Th1@Ei_R;=1!EY0EnuCbv(7S_(a&2WEsVK0+XcgU;)u-Yx0Wp@>q zDx3fSR%MNkjjhB~vhiNzp4!~v`g&%}vq$S8R9y4u*!Z6jSUY^jxm-meYd=+1s!a^h z%3^KgXlr3z9TPsLSfoF3V1#y@*-ZkbT36?|m9Echow}Rrj<*NOW5+gM4M@&j<*7MfdP%IJD>F8Rk3&oguc6c?hr#3+UM7BpCefa{Pm zb>l`6gyhe!M3HzyxK&d`h=C(UN^)*M;S1X|THCx3Xs#iJ)fa)o(-y!lpI-hu6^d4G zl9|5=1B?q5DEzziv#zq%t7u})rwDdQ{~)G9#$Ls`;7pg%MP1U>0erBqhkC^eUFD^u zj{g4p03{ENoQc(Sbj*(F*#b7D&e!rfieLelX`$(FaQEyf4>rRYLZj$Daz$w}^RmAF zPOO7`tRU5dB$YC91w^B}lLO@vG60@-E1b{voS;$H*Iy49HK0a#5AfFYs}&OKH`A;j zcD&eBYf1f9i_c>Ov{1$R!raVEK3?k_GjVv~F9US-$nd+hNqXicNH$cj&k!pG%_3cw zqgXmtn#4+#VKFGS6?U*BV|SPWR)7`=joG`a;5y2}WWw<8@D)GdQd4FR!uKHvQ7VAz z{`Q1<7~y)V)K3DpO3s~)D=fw2zX3PQBu*6a<=(o);NntJIbktq+)tP$BNBMD#3PjW z_ANcW8fBKBb|r~tWIC}&2K$k4DA$aVw(tGE0@g~Fx3uk#KcL?l>-XOhHxcqrv6J^X zO3o{@&+PvYb5er|tT$H`sXb(nS0n+I5Y;6v!?h%D`W41mZULBI9drfWpt58}J_mH^ z5jJsOk-WGH>tq++4WntI75MPSv$zy!iV-|mRM3Q%@6KbMz$l+jtg0+vD6u`%@i$%I zbE29;+eMig0c-aM-J$S8m<7>X%3)Ep+CI!}N@p7kkb4xVfWCU8?GZ9K3@bqRKAkdj z0KSBP?+bAq@KOU?E@peo_Dy7Dei55T*+Bt3mMb9X>I_vnY1~~zqB-Eiv$L9N%hs)* z(9svphdc)9T<`c74|f|dGnJ*0eaRnIlB3m$(9suQ*rh_03A_GEr4m6Mo{S)pKeld{ z%b-xyK;2i^Q>+EHonc7uXz=YiW_>1w<9Z|PzaOsgYXT0+RjgoEWc>O`nh8Vc3mFe- zXGcfD)@c$efr6$TZwuE^V8y#_Sp@_HhRrwvUc>(rUR}n+S`cAg$Kt>;S~Upp!j={E z!b4x*LzK&Z$GUn421@mb9UHPrH4?EjdE+gvPMJOE_Wmv%9~=SqAa=Ystc9tbs6Ven zq2d_J%A%i#>p~mecEdzfIk^?m0MN_qapGOa+K7IU1G>GzmK+gV?mZ#iv<#yNq}pOX z@9X2!78;9|KSKE9#1Cce0XW>QL>>O7PsCKjEzH6mUH@B1^UBBpb{%gIx5z!ID|&e7 zf59UE$Kn}Lm?@;ErI}RJq?xH9W2^nyT_%pbO&D<f$u z>2un52z>)0wC`<|Fwy~v+5mY(mD}3&>l3ED)XF@QjnlHSitwIquLW0l9D0zd4Bbr2 zdqAdK&mq21cKJu(tis+D>vusUqR5(6e%O{}cKcjx}DR*%JTwyPBEKXHot!LDNCjzeWJ z70??1b~|pD2&Tr!#%AoZxNag7^#0eY$bGqnfUIK_csK91SfQDM+p&>N(lP%{D|Sf~ zK*dy1#O-?X_iW1w+Ho-e$6=LZI$NgZSD4jGBt|#pUJRSX-~k(u%_+ zM=I@|-e$Dka#yb=Rpq6)+r{B#2*ao&pem0@>PX&4RR)G~(}}t~5EjtpK1kBSLLV4f z)V4Pz_;L7c4{{C2DDnP=pF)I3r68yyzF7w#ZeE_AD_?sc*^a>=Iz4?0Jc9TBpZ2c& zpUS-No5@7G(nL*`Oi~e|O-CwYi4L+POSA~ZNVY0<^t_&b;OVD%P2xJ2>-u~@pZE6RomapWWz_nt0*1+!%Fju~ zYj(*tTg?9{!cUi;nX#`c(MxMA1Ee25vjsL>w0oHM(NIN97|aj4%-vQWhpu!#?A;Be z=ph#&%0#=h(0hd5u+>x_D$gG>&E^lY~QkvY83VLO>x0lcRYdnJ{Dj=U) zrP!LBZW9y!RIef03(XL(hRF6@9QqrB6Qr{BEsir-vrZ^_ng=pt&qYyIYhGb10xfEFLU3)>GRH7*HnZb=puFaFA;# zoI`j~O(%3lw-WHZoqmbxoV$eQdT48Sh9RV{k!hCr(A!JGlA@cU?_$B*m$9+D^NZ;w zPJDJ|=l^y^2fTs{3JRV9`UlO!T8+e@DK6KNGT(U9roA5Wm5C*tRm^UaX1dxlowNSY zFOGiT3f6W~-Jd3Mr0PWK?ZgCLnN*D={%UPbP3pN$M7CuF0j65dc9(2X7lJ z?SLLhnL@G7m4uD^!0pU}0*lzdUak zXXVd7%f6r+1 z3`}GzX*qGXZ+`$FpzSz_NF&>VbC&_5N~pOD!b7ed zVg-^1tgwUx!w>&aq-{1?;AZ8S2r_{v@QqXP64{|O8L%Rpj}y~MaTB!IAE3RT#pyZr-e)8{YB;Vw*6+8~BRoU0$`WJQf8AQC#AU(~81B0tl(; zojzt}b`?k7Y5+Po9{TINVa=8ge^ZE_23pqyRyVH0mnrA_GZf49ovs%z^4Ey@A)$f5 zORBBD*rlD5oSbYW0n7{m_&F26hQ#OwSR2FX@j_w>g_SHBqBh4$9@q4ymhKh zqj{mLH0Qm03Rni-8XBt(q9NPvZRUj}(2}p&aME&diL>7yc-0T$ZM1pRiYpZ^RCvqL6I*drvd!#d| z+8lAgI``y9k8XY{1Q_@Z(rLBj`vL+qMspf#YnR|0G_Xx=<-7-oLM7os&stk)YWG<+ zH8s+5a>1Vpfudne!85g6dBj|m&FSy2oc>ikL>NKO>{eF@j3a3H5c@IDn@U``Fu5k_ zZb;kCw)XZbQBg(_v@kUX#4ue(1aG4kM@C*gjB9ruq)GJN%D>o=*7gfVEab@7LTmu= zEABla5H9LS9a%0| zHXu<@6F50B@uVt4j|7{8quf`n+xG)WI&+JVvi#eL38@>es^iEvoBIv>P(gNu>&!sw zwN6zP9W)pkDCq0=m;z^N}Ya?ot>?%p-)2&5tqxQjxtb+ zwwGjBTUjlH>oKaxzoHu|cj7wO?x1gA@Dp&Nywb%>mp-X_3eC{5`Ng)!oFzg0;tQ~% zW9r)bF&j~>*w0~~@U*MjMQYQ=SpRfcf+Yn62l9f=xEO*f4H=t_t8jg zvyzgSm-~-KM)gjPzh^8%O+E**mL0`HngIfOHn&oNg1kgzopSv!s5(2~%&k;>@|S7% zd_!4A*aGE^qU+=Zlaw2JY4+&f-QA@xRiDU$Jj7FQhfjut<0nq6Yt;ad6wXyRgK7ba zEogUQq_rdnlN1fgDxW-Fo-oUwzXLR~;aCC2pAi(7qbg8X{g4C=MMHd-FDKJ!(Jk~@ zp;_1sX{zoy0Dh~w0U9pSpVIkI2TPw#TY^lSLd!JGMCB8j%%Ev#Qngw~CMM?W(ao5m z%J4t~w#Nh(Yl^*o?<_F(j7r#c=J|MR zj=Kj6iXSI4Uu%*PI6Kp6_=_KsG>E@%fQq)SXX3+Kj1_n4!w>sh1SuE3N(OM=kxVPu zLgLFH%X_!gKxwkI@3EeF7j{}kqRTtV!-=TzJG|xtR4@YXqlIqaB)N*)(FUBi&CVK2 zN@-N=3Ee6mB`xd?vuv=};`#GNGt>^}LpkiW`tI7Hw#SwKsFC?lF&^}{-T73^16>bq zvpkY<;^=rmNPK3ZsCT?$sRcrIyxXy{cis*|QM1Ng#qm$`9I1kflZrGA@T9+;=WpPh+`UdE z$l>0Sx~3s^rgm(`pugfrKb8DQg+NCBLI&^DP=NeT<2MZaf60EJH$D{?x^|MLei3a} zNatq}&8E6`$_HMS;xw&i#^m#;p3Ahk>fla>zDv|G*09opl<&Nl@U3}jpYL#D3dfQr zXdjGAF6Wp)MUar3ZMhU#=Frp*6dfhc{6=rB;#lv(Xaj{yd~o+BY>p12?)T_jOErRtU}JwaSjnt2t>U^Y6HVNSt6&^orNky%Iv!3`vAvC zWBth35aLG5P=E%y??IN(;MLjLnT(;E?wV!>X`9H36*9c@aGsEb#rRj4DDzG?XpRkm zgjz(zC%k+JghR53U>WY7?Yx~H{eBo@%bJ@Hj}0l1Rf(_#0X_pSbR9^}>S>_3Z;^yx z?3w9!&qrs>B^FUDwscqIME{7Xw(q9Sy(0Ll06WlK#l*#hiRuI}9Q4$f^)R4dB)^W2 z<90w0D-%5kuQAYpgJ89UDo(d|L^)tni5d=>FD4O8tJr3x-Ac+Eu)T(Sy9O+uJ$L^6 zD*vZ=&pi%|2Cko7GNBJpEjX~L1(WJgh|HiK91XV_hH3i0eudzLdSQ@3pI+JvsL@N$;N#+)m_KH)fa4H=qgM8-fxBk7} z2;0u`kJd*z=)^!nkk9Y11{EOuCWe`6m>7yPjm^Z~fJ14Gmfqzphb=5ZAmBLunJE7e zAz?vzQh^e?7`WAQKfi@o7=s_~R+|U89CXAIqcbYFVSymlvf_bP1q0N=$$))N_tbrI ztr7v6+nqYKezXXZ+zPNu@$9iOA*p1zjXcP&az3&RYneu>4(xqIx2=JtG{PY7SaaYvOnQ` zP?P(SP>=5*grK$#!GDP(vcA_IXSgzUIH&Fg0{aE?4$vU{OcGZ_W$HvpEgbu#6UI9ce;IV!@mU!g@H-2F#z)zv6%TsX#o;}5H+rF0 zTtI@5a3zCH3tl4CO>Oxzw8hlQC z{CE|9z)F}N%W%W6?}UsD02g8SB7UF^N_d)rKu)WScR-JX!Ay2@8k5M0ZpO!3a>fPs zU&7Pe?5$G-1ZFu7&$^~GlqQSbi~fM#uA*+J*jxbi_Js&r4F#2P!tXlwPN2~X%LZ)7 z*5~S1SWvV$yXfme(^B++@38Uf=eQWN=R@+t8>icHPMdbU9R0@v?S!KSC0JS*`4S#y zEqe#i?u)NnS>6d*KV|K<(>JTFaQiedvh)S;`TQOdpOsmMIk=&_JIQA$n5g%^xE7T$ zjZi&wp-RMVZe$GNL3OXq1A;s@Hg@Xs1*n4yl5#k2FLZ08o9*K^L19b!8Q|(_epAXj zoSH&d-56qaVYPQwJXYt1qExe|Ctx|R*Wiacr{e7phO8rAN*>M16fmJ988I}P+~DF% z|0DK|`5Ur{hPt{)9}SHDC+2$0(ixsSo`J2CK%AB!QI8er0uwL2j@OjDe_rRK6Z2ZB zn9+X&#)c2a<-SK7VVhaEclKKl#LsNI#?#w&VYf||ovEtT)z>e*dYHo4940h+nf0{= z-{3nV7UG+GC3lxieU_uulDNi@I<$HFZ4nY;B zL8Fp;jW2G9$5s?KNHvbCWCGE|J~b^zcVujXK78@;HrA|I@>AiPgg+scMC=T%to@m& zw+F|0!9+U3PN3lu=45KbRif-{ixs%c%>NgV#t?Pr%$l2+JohVXs9CshVYr4O3^^Dw z4|yhnv+Xlom|*%8_{-Q3c*)aDtED#tFi{hB}U=Lu8i){j>KP)T^nu68Sch&eOaSizO z(_J0U;8GlFxft8-_aqpmsXE&N<#;?HrNB-c?UVe?0zdrmgaN*&x$l8lCH47rj!gL< z$0B40-vfCW5-z767W3{?m;`*hzI6BaUPh>7X-$RK@{>2D*z3L$_spZm_dTRG z8aL0|BQdq*pv&}azIX3EBL!wen#x?uHR^YSk`;Cn3ZtuzJ~AGN7?>ZSV>cvTzSyKh zR8vTJ1i~xUUbhs;ssO~vGNYn|n0o>^qFG@gKnD;i{*HT0Bz_P@ZrY?Uw8>sKvrkT| z=uqjFFka?br{VneN|2QSabdW2grRf=&idb*{pl71enGHz`Aj>J8?Wae#oOB1DJ$ql zNpps8jp+@gg(MQR8@GyHktXHsPta&El{*jZDyM>3LGxIO=8;((y?HV~R7MuWW5x8! zVvvU9>AbVUWzFzxXw@?iwu|whNC#s{S;&|NEYNEX>Z9AP9=BHOg`kY#@2CE7Xv8|n zqPIyKZ?7^82m%5YIVZ2BU${9QCoQaewsV&S1mfGy{9WGec0fW8fa9L7iu(c9tMDzF zx?IU!@%^@$RuS;m>-zd-eRYY8T#H7oKbSdqaz88sWHYtZrK)fi#ql1+L9OJgR=n_d zM=rX2|EEd4J8W4WZzZ%>%27H4!iwjkm+{6x0$r>!ukB)cEbCfuupN}&4vvo2o|2em zg#7*dwEBzPdai4GSarZKs5Lo|Qz`iZtDn|1vjS&JETh_R=Gpo9a9Ry%M%_y2HqqG~Rk3$;>_L`=mSa0s$q?u* zK84=wMApvHD|evYoaL85N9qSvfR-asCHM9Bd(7zs2(30iO$y*`LQD5cvp)8I+mNVd zBy5z~|87!<;Y0(HGkWC(C~@xZ2HG?QXe%sB6?d3jUHx9=30~Si;8GqSWv}OnYfW`^ z88kQz@hw?4VVKR9al{wTr|Rpkut~$?&>R_mHKIZc8m_Jed@oqNL;igfG+fy2($Yb~ zbs*#>{~v!1=5NS{=sKwj2_IuA#?yfNLAD&Y9hyS)O z&p{l0H!I|9B-r}ge=CHp`cJ15M*nZ8^Z#G^f87DztUz%Mvu?doH%$P4^t27NvNVrh F`4>QRrV9W7 literal 127996 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 `dgR^{ zytCt_>ctuU6^)vUrfKszzYKJ}v+!zVokqG>f9e-z#|dEj0oSZ{Ro1Gg@}-nQJcQ)U=xyzy_th3QyX>=|Y=!>Ue8><}0x>y96u z7e7y>uZ?@SCT%Y!uKntF%+M7GGe<#aa4u9nWD-i=q=A(G!1@?9S-B`#>gvu{`OV~a z-4)gtLSHy5YI=tbO*(B|k@;xSc4GTgp5EUD$77V{s!IRMUalhou7rpa8wtGAEwBTS zUs1jwomM_M6T#hXd0T@qD+L9mgx->G*1XRSR zWTt%1NuFlyPV$(hKJ?vv$#|!SU1c%buUiaI?($L6D_Y=(YT;%t%Q2w9*pDDr&{^6s zF`ZJ-a}rX(5S1DawS8!L(uI4d!uGUD{r^q@CG!aT#W)-fy1TH%nd4TL;4k52rUzCr zsr&>liq*d0rwplf2OJ?GoOd5UDKG=zOoWNV`fzT4c&I4W=U^8%1?m=(uO3Q{YMt43 z=Q)r+=A2|oN|@cPo-@9r@5NeQO`%p=j=UnS^cF!qq-Li%?);K$n)X**~dLf61mmMPKE9mT5+$4}u z7@^?8^zJ~EFezvV^qE`pm%Brt!g~2fN&li=*wJN=bUwyTYJeVH{ZW6U^CPLj{Ur{? zlHdIoS1`ZPOdUB_#-1;u88aF>Gn3zzT1(nLX|~KDB)1DZuoeWw(i5;0qGJ^~KX3>1 zLPO#bTt8#g4EEZ1SErboU47Qw1N!pc!X4yC#8b^t--n}HZjNDXm*v@Q)rf=&tirh) zgFai6+7_|PtTf2|-8l*(sb%@IX*peuG!|^I~6BO=F0n@*Z zR$=TRH~pb)21S1YaB*2jbI2V*2XwxW&cV&snS^$)Tk(d8i#({p1vlLp|M-pKx@XAs z>^&+wxDB|OhaQRxC9ow8L_Ny&G=eRMV8KB3lf6Ayl9hq2RNNV?Bdhz}$|8mNvn)?0 zIeYBgFDXj<*-^%iHeQhYQQ>@BbHu-`=j_3QPV5VR0K9+E1&x$bHfywbhrDiVfpKT@ zIBGZ5zx)pPkX#TZ}@JawjOk2CspO!E?LRSftckedYio4YfC^2KH4%fM{h zvWNHKEn`h$Lh6?QXg7x?s>#_}0SPyfHvA;()sXttU6Jy+rcW~c&d+%VaVfNSPy=v65mBx+3o9? zKA6Hc?bpNZ1b4R!ghDm(1Ok`Bl|D7u;cG)^4O9e==!{0ao1fg3bd~DIXyl6Q81goX zCm;bbnfUkPk0@BwlPuXh)NUeM#W!#7iWo7#$)j!pbNK;aSGe~Hq;XCS;q-o;rx{QG zn2V7US}=g=OXC5x>|R&U*MRAbldj=wPB|o~gy{wDTBh2Ax?jkuo6MH7gxj)&wve3Q z%V`7@K?U1*`wIqy^>N&xLbx9@u7?Pskn1s}yKD>U627yx{KM{5_3beK)vz8f=xy!N zB%?NCYZ66O_{d^pE=`d5N-_cP)75KmHkNyenAr*X_G$7lBQFs%4~qhoIoitLI1B^3 zQRoXct#IKzf_v*bV(O5W-<9R@3s#R-bE@&y7Awojx_Y{INKLsiw98W0bUx6h(7u03 zFV4~7b+d0*We*x;5{ae5bS&-B`=?q_X`!}5I1mkbXdzE7i%(&1i*M$qTXj0t?sfNc zF3wP?qm*OUMyB-|Cd$iN&(-g)NG8S%Eokxo?U`NKVtr9xcAK{X6*;3SXx_m$@!}r+ z8%-M;Ljx84?w<1TKj!9+J7#%v(Akccc~EnU#>iBdEOdYvQyZ2BRkO7Cxh3b6w7Wny z%Nl92zm^~;vm7R%NoA`7d}sh{Yun335z#Jdy~6TT=jc{pOv7tly_E9b!w=GJQs%B5 zDPz={z&TbMiUosY8W>vA%VffPk<9wuzefUz6q`_?M!@SIp>*IqNfAoKR5^VUxgDt)#5nBL+5tu zy2KmA;Y4)n?IUN68q$q51nxrLdGXAfUYQDI-(?ewix_@+Fcl9l+N;=;2R`05DyCRhELuw z>^BM(&?MpEifaXH68?6?p|Ddf=F+K&PK8r6q|dDXceaHV?}Kmm&kW(uHZ}TZNDh}n z2qX_26L5-HWhfmq-(P^jc?EWJlU9||VMa9fgYFYs0>wy3&?(AyYGrSw?iKCWRY&j1 zGJE?(GKRb9W^a+-40RMk;bYfMhnM|;=oG=T9I?vr=h$0R(Yx^EUmqKjn4{L8;&

z$tBu)I3&IW)dVvXmUCP4IZG^G|Y4>TUshFqFvp1#<5Uu)VYAGy5<--ix@? zp2rto4_U3$R#=7cal13Jq*@*~9@FAsO?h}EN3nM<@(z{RFgd~$)dOW>n@gNk)u~q* zH3|6q+1T*G2p;5`lIrhun8orz1==2pehVFfhSz)#2%p`{LB9Bgtmke@XZR4FC)BwF z5z~QBuv@5)`Q!?1p4lg&U03-Cz3q2_J<@I;Yau-QA^S{rtaOl1yx$_rfUY795naKcuSxDi+FAJ!7 zpKBnYwA2{6!Xqwko9Wm7ibS@cgl1TFKVv*Jz(PO;4cPD>LLkn5W4OUwYFc^u%+w1R~!7u9`ssxywi}M<$&r!m#7uDt}n?9*y+NOqQJIfnp zS=19B81EjxaEs|qA5GwN3Sped3(@{>!-iVw(7YMr_|MMxss(?he7P-|7)OLG(Btwd zLt@~3JqEw00=Fjv-`k~!4tbCfbOUV04;NkMJ-3g4A6W-aHqX0-?|x=O zAV7~BTAY%gng7#RGBZ*^+17`B5;$eeTK4qoQWQ9Cb<13TAtPC(LT2rCu35(Vv| zo>3J#cu8{3^KdXSoh6C*2_FB?YlqLk6k_9 z*cqb;xS{xz+e4Ky;REus7V)g#%iN!2EUYn!_R)Va8jZdz76ME*4BCJrM}tAt!ec}6q&a913p4g?NwbJ9Ag5M!F z7Kn&%p|fz}d)xCxLojBdcEWf-v_}QPo3#ydnYD-s5z)J>8VsDN(s^!?Vl3G>FbXC! zS&q?%t^&(NV~2@y{k~RFx-NE!ajR_hDl)xnfseDw6AQP1*ResZ zC9-{KOBw6tdYq(6?0%&R$B)_51B*PrDWAuwhL-AtG#7sgxL)TSvf;k~nE0|%D66_~ zPlO~!82Qp6&7l77T^0vhRtRKQ2aX4}1yy6p8zx{Ca0&k)J*JA}q&WnGJHo7}O#0db z6`WO9byM}-b$6a{5|%uwA!nNWlrar~&3l#K+CaZ#a*@8{n2QVv6Qb_pHQw^%9r~)B zc%SyBP$0#ReE#nQ&B2YH%OaF>!@MVQS5k!bMg3BBWq!R&=1tYqDaq20Ep)!3Ug(@* ze8bZHT;eJt){$lAif7ts76c=H{I(`%7HJZ*;BS}6pDN4k_>chpCW2?ejs|1$u8lmC zPK%tTI<>d$0kvHPP?kEb>o!OTAxEvpB_)2vI4Dy?&2QXz$(;~G25H0dkzES++_7|v zMFVsaHlQn?6Egc}>7Md9PZaG4P~*9ihu#KMJvey}uQl5k@*NAGu4(#Y6zS^-u4MDE zFJ=5mn^UYYtU+RVDa?Kk#i)Ki{70M|`X0d|>?VX0#I(rQ^Mx%MegFF=S19fM_a=vr z37n3vWL{oPe?ml?#zhqPsbX%I8vOd)kik4OZ7iS9I+02bENOd+A*WOtHTBJt+hwIo z87pgMDLip}LPMPi5FoCxDFe^MUPm9aa-vdy-bCrO#93hxJ0yAWciadae;WeaEqmB?>IJLuCHj9J;`{f)^?+J zqGG11`-g`-5nKxaGhL#KggAzQG!)|CU%n7%K=-Wn1SARcyf%j{eaWpk zAVe%^yUbpF0;LZEX{k@Vyx8@+=|lx-Wr?`wPsruvKT4tAag8$H`$R4(ws79Eg+N!a z;Du~!Yafr|E$>B&jV!C-8Hqtoy{h_|M?Y$7v}cLG^oM$OL4siCj^;zTbl&F~Q5tob z_Y-L!7P(aRU4KHQruln#!X#{Z^G0!j^Gvl4V~LG(@HO`S%sZtY4ep1wE=@hd2)6gO zrF8^wgM#1mE{XEVAmj=5N=?o%kak!ChxdksiaGcKp@cv z0u(NJIhXXOa}Mic++ZSs*5%evS|L+NE^S)!Kcr)m^JlQW2S99Un}P|KoYOJ^m&25x z;yZF_9jfZ5C??B}DoBMZUk5^iNgW+xEE~X0;5ujoUQ3lt5Q-r6yWdeDsw-zN9-~jlw*0%vmle7zH?&C za;|_IucHHWtam_h5m$D>WHv52OY`#6;>_Xu8NsKX>LmBb>$7rV0PtF+r_XwSrcfci$@o(*1*D_V-Z~z>4PlDKu-DA`$Qt z!xq>IhKgDPvpykzF_+@P_=nTNNI#)(Jwo=jgsV4&UM+dArm1k?o!^H2A6t0!;~4N^ z$5-HIr_c+=nxmc$k+2*_46MAiqNp!0_zU4J^32l2RXge~{IC0}-TMARqG>r^NX`zC zsH~p2D@7~0)J4%pf^P=CE~rz@h<5K+5)4D^42e5^&SIArPniyf&yK(YS7A7SMu$)Z zv8Gv7Sh%y67fWX~K8HT6vysIulYUGJ#)B71vTNN(;WCdu+}oYjA73KPdA={?S!BdBRR0qE&WVF0mQFGX^ z)FVwNqOZJnzG1A0pn_Zc2!L*B~~hrSU|hm zpP0B@aPA1G+Bhw+$KCSTG49bzG8SaD8mb`XNyU3Au5#^-2_I)CoS=`pAVg$@pOzzp~NgTjImZ6e65VSc%u6FYl)-#E*Ad^-~1bs-Xr#9i|Nr=ey zkv-VM*o%NI#fT#mAYEPq*hd2d2_C_z*as?H3o$NT7b@m}(5}hF_cz0_OGlQMJ*`FU zG|50(7I|-5{FsBquvOSD%KlBAHVgu{^`|!oj70UNfB_!NX7Q91Q-`a!_}f}OeGNgV zKnG6ORi`>JjEJG0+3nTNx)txYEeo;V?*JD9yoCnWM?E>TWA1AeK;tOi+*(Z~_3ECj zmU_Zc_zM~CP`P`8#OmnesG9TInvtUm!3W=s7M|8L=vdxX8KO^1(o#B7Laew_b$p_O zKRXM_x-p!2-1sU!L^RutN2WKABH;AkcoHNvmjc*NGv&kkHJd1`HtO+#7P*qpNI`}s zNlYD@wlf=YWw%D8Q-sI~r-YizQfg$aCVEw&OJ}qE8lRx}Pd2SAl5m80Eghs&YFIA; zAaWcK2Yw={C6DMt5h8kWzjz{g?4VG8au^5Dj1;+lANRxK$gvhW# zAbFGxm$+vj&#L}E@~l6$_TaIccuj&x1hrIEpyWm3VH~4FmG^^N1bA=7ay&#+EO$Qu zGeChc99b;laF;S3o`yql?q9V#PIqTV{ml*;L3!7+@D@pKIp( zxD+|pZdROD_3|W%wtwkc^2PtAyzGEs!bMY20W4Cx2rxE~I};!ioV7S-b{8tYUSIu= zFlCS~O6#E*Gff-E(V#9c6Q%A)53KCqT~fSjNWutxh}ZHcD-n`7fX?oFga#Ug?#hRO z(;GQqD~e2=4+d^Tl$bu#xk|=kZYlKf>IUVQr!^wSeDoyjdq0LJ+eK7;Q?!*N-`CHL6T=-XB&v2mc&bX$FqOw9r=QvQ6_C*#zfpD z_wpM$z@<+6967#dSeN3!MHl|%<6Ck^jyZJJAtDh+jB<=S9FR}Gwy9G<=J$)?WQLY; z9V50BPGt!kme;2lPT<>x;0n>z{0mY(t$ZfC)sI*P41~*2Rz^8HgK_-y*aG#aH;l~R zcmPcHJ^o0Ix+R{3vl#ratP5IA4c;K|javne5^$UT&!epK7WUIY6P3(u1f=KWyCwgw zuB{UK%F5CvV4x5O8T&g40cu-2>^c0DHv{M_(no{aFtz16d|a6wqjeA66ICu@<$EL0 zImF&u#y{K9rFdI2-ztW|D*f3A9VC2 z`YxaEdwne;bTP*lAeZx(#y$R114@R^C|^@mV7>YamhqtB>C2SaNxfIW+d=!NoU%>m zlq>c|6_GK-?$Xdl)3qg`wp4xrL*cyzK!un;1|snsnG3uZ{(^} zhw=?mSA|V;l;3VX2q+1MkWfyQ+zZHP`A`M|srwI1gGpkoYu@eXpBBCu@5Y6c?Pvey zaU9JQcoyetM~1Ev0&solgW5~y+pLoNGTZh9SxI>dIvQlj^=^Q^t=#q0fn%Gw_+SUmam-lA%10L%S0sUY0MO{V%=Ai5G#@ork5LLZKZ zWf(dq%rWSTr$uyeK=8d%L=-=&67~oYB`iUFo?;fKr7|{tYxYXKtWuBY#ZTvN^};TS z?R^hQ#%zodSxw?HY)zQoJ)|pCdvxVkkhRq{)X$Vu(^-*qPeTsxEnly(v5$Iii;f1oeTcYb&d9SqKd zCmB>Sx)dmt-3%~I><{B5v#1h_=kpC8;hIw)oDrA5^NM22XYfY8o6WI<)p z)I%AFj&bO7?q|UkyD!M2(P zSS2r|!$%H|bz^(lu9YE=dwwtNe%z+PFYpyVIv5vvAO4M|g}hmxa3CXKV);Y@iI=9? z>}%@v9#}Sp|A4aWISP^;+ll1xMJe=;s|FuUwpm6y+>IVQDGa)|uy=fCjgBxIW=Qz3 z`4i!c;OA+`y?)0|+*yNB^}?xhVT>k%oOjmkIH}5k9e_-21;X891kUj)nF%XIN+&4a zB?P=foDNQ@_+={gcGD!#UzZ#8JvmxD_*HNkXN<9|@dIP6jF9o>@UF~j7#^=T){EM@%_k zh3kpOwyuq8X^3;#O?7!L)nz>kx^3dc24C-z0Z4T0OPVN%_Lo6OrRSzTq6hLPz91VJ zh^ujX&n|7wJ-vf(3RtEzh1>ac^K5s9f!dfgVT;DU1gEE5f#TWoy^$X`c0#u_y(^Ws zmxo^kpkBkUFVUhnnjQdv@4qa@({h;{(Ce zPXm=}N&{kjGBZZsHal}uaKJXzV*@dngO#jbIMHHzmnfKK=(t1oZ*BNv31)?u^*c%T>-_YZ41K!iQq380fbfn4J3+XuDvZ*)H z9b6UCWgMC(jl>!xTNFsWd6wh~?-M=0&hJhnoO@ty?Al9T_XH+APPV0Jk=sp)q{LEk zgpJjN+rHvlbzz^3rU5n!!t`0lOfSEgev$@=Ta2N4IK*k2^*Mo%kl0J>o~q52V&_j+ zv9T;fHx)rbgE=@188HSr45o73U!bggS}57%2f40~Gm}zkw;wxJyLPChU$wS+n;F$b zWTqcOe^+r7S9|xBGssy%EHcuDv72K8Y{oU zKTuQ1o`K$KU2HTpBRwMiM8DCpxH&O9EtU?FgWKI5-m0k^hC@x>k@Iw+=GghT;P9qJ z*rzF*)t^P?Ddt&44~ni3;DXCQ#SsuMfrH6BIDvP*u|xfRq@KzGw;79d^6I0kd(&cWemIHc*QcC)(=9rH%*8vY%BEOQ9f;ZY1TwAv|Ri{ z<@fo{FUvZ6Yj^*)F0%aUSkJbdh-7XR_hB)~xzK=@WxL0v4cLsTd!F!Aydt66iu~NQ z&>kLabavlN>0DhFpl^R&iq$%HX?F_@0lm;~2u1k8!=$}sUT#KX2MNu7bG9E*?*>mA zb>A?3^4~cN)ELQOoY?timUT#ow-&p9Nk?AtT5jIG!ou6UmMoh}1>Bp!i-V9A#zZ-7 zDowpUx?pXmdvLYMfDixB`VFhmiV@p{_NOopW*UeA;L8&^iovN=kIBCv4-wIg`w_K= zW_CoGHnyQ@f%BdZd?bIm8p>MSy4_s%92;=-mF(jEbmTAJ-R<Bn?!Ag3SlpG!YQ1NiZ_s3%Xfa z`;G|-V9l;LBSiGQuxdhY;(cEE^+TvJp?y9o^~$~49jUTIRe|Xf)tcNHAhMYSQ@ly8 zK z=Aw93)}@7j<-B#;$?$1Tc zSpWIwIQV|XdoI$xBTmja8_RBp8QkOlzIgK8IDNhh-i-OtJRyZ_Y4qdS9D)>9Xo2>c zZ6ckwpTNJm$=LF5w!#sm7kcriLTYqH#Ns6ns&{3Ht8s9$pAnjMH`N}NhH;ll0$9gt=!d=9Lk#exDAFh9z5fo$bVPT zeM1-Wd;MCTk*cjmLHc60Mtb^>gimR$QOqmA>tNa|$ShtuArDQnjA%P~yi#K=9A(_s;f+r#ms#E?IfqxZzD^*deC z(8ueg8Z&&v?(1NxRJ*5I^mBl!C^Qqb=!t!HuKv{q#l3O{DF1l#t)^VIL;{3wDA;Qu z1)x+m@NKTV^nOp(U-8THi|3xUuQQfqDoL|6q^~ihb3(s!qhOtU?SGdSC zqeaQ#_7}6;8R9otgx^UaeeDVkUxte!e?I}iqqPI&b(l*#<$;Eb_}g1{Jk>kBe=+e9 z{_TCKz6Qmot+=)px+j!BRaqSt#MJ_F^fANOIQUy z{{D=5iNHRFQ)sbx55T{9N%fdTcz%~Y@Woc#ma%p;tG9lW=g)67o4~^~tggBZvPVq# zK)CcsvlF4+Y-H~EGq(V0ns!Yb<;WI=w++= z)D)w|-NxS|+G0Yg1wP8&9B|aN!5a2!o|i@@<<@&2cZ#P^E}0fZQVOKZ?)1aE2N0|^ zWq8*`spl0neQxp(O(q_?=d=|tX!Repz8+E}=e(yjay|TJsxDax z+!OeFLF@g>(Gi@|0RK~kn?De!MN(P3iKru6OSKNg@NbQQi6VK-wM`3k+c(m2UiO2H zNehZ6kh;dLf=-Jc4(7Xr&y0&6rmMWZWUBPj|Acmqw1IkPR5AUH@*-ck{KUOYHVl>4@$vc{4Um=&FNqg^diztq zUy^2qyf{H`3hI*ir ziKtdfto7y(Rkm#jzwZ;|=0|b};&U^<#q7H0{qp#Jx7HFeQf4!ClJ^>Bh{o+T;^|z^ zn=@oT9nW~8qnFQ;eh{y|$EOXJOOKBV8mw6oj91Hjo+}hc&Q;E>j%D*!TVG{(^swg8R~w7qSiTLW_K_m-fwk@$aA>yz~8dIDXTC+U+OrI)Zn4MOQ zbn5Ne?Ac0Hfy9!)6!BPK);F?$9Qx~W6fl))G$++AzLsw{uFYQC_ISo}+DUw233(49 zp4mzdm}>t2evZd1mjSp^2@Ys#puFe^7X#GW_xG|%8GkM&>$S3C^!Bt(!=uK3sU@j2 z;dwjHzDItTHueZ%Z;uyEd#d!5vq2<^c_E4&5*+wNAu(SS%B7|45r%SbKNC3qanh7w zDfW*GvI);3^Z8swD8bIcJEU@Q>S8i|S6#4h^ar39Zs?vPJ63+%m_Ly-w|dZXAtb+c z5plfcCEjrn6Z3Ak!nwgC%wTTKGa4xKo4W>RLx6ofeFj6YB|f*vQJ=^)V4g8{tJus* z@YIack{i#ftO2JeP8GUeRa6_|_T>hm*T-vVPsU{p7d6M{5GVGgQQSaS#{acpAI0V8 zhdzburn~heLS;MVrwaXzMyjj6d`QX&V+c;&SS8zO`* zPOa;=aQ@O?Tft~ZBzh`BC*v>&ElEaMl9Tcz>&s@`11!^wI_#^`-#OoIeIu26b%JI- z5b8s&!l|Pr*R#(Cd=4tMwz@I)vEB7td_XDXdvqW44)ue=kJPC?4s2>y4W zAQa?$n05eA7~>NAI6IB{#@xaLy;*DHB`_tnRNYm;d*HTCk}dk$rx4G#9NHk1l3PlXgRv;GcAd}R73WnYTCUT&*X}fR#2O0hySDd zkc05>j-yEu=HqK#pJ+dL(db!ioj55`Cn5277Qi$f^W$6EdBp~YQsoff66XJP3BmLD zs>1nH{*}hI+DwY#?p_Tq#6@c~!{WS|Q*9*wQP@ufVqAE8#3CZjlJua8=x4@e^K*fs z`7J)8_&fYtf^tNMd>EY;N7fGK(anMW`R}{49Mq8@yl>*&h>x46LEX zP|rO!QYTQ?(4Ppg^as89&bKZ z@F+W9sM+JpNKsCIL!zkpsf}~ym}guw`|91-mw(QaZD>cAfdL%S*7u;$J&+3frpBxvKYpL<^-wRJGxz7S*%g-C%ERhHMgu*k=}5>g1Mz4P^x^A0uv%K<>hvvp z)a3y$&SFtIo^r_|6Pb$+bxm4ul@g~8sCA;CCSpCIw&lmZ8J~Fam1&S8Ys-;|s!##j zs9r2nEbf=DwQTtODK1HcSaVDIBU9D+iGf>D>S8d-K7n?bZfPZ>XJs;MJYpqcRe&Z6 zoRS>l9WIQA`ueSBnR?Nsof97H|KrAU>VFy?ra3Uo_+jXHu z2)1SyH{1Y|wa3`SW|m907Q;){p0sOsg^b^EP^5?(yBur9 zgAL;qcJWf}Z%I>QcQ}!%z45PKVA1x)^djTKs=wp^ZDt0#iz7EPNSN^gaw=BayYq5* zZW$bIHEsX(BMbJk(u)8Q`MV`J@rURf)Hfs@qGt~~PhkPeu~F2pJ47afT!b?*gOGLB z?a{HuV@hHPr)e4jn^7Sgm&a`#Bq(4(*DI7m!xYdwb%nwGu)78Q*R48q1Nz5zOb12i z>LFdt;g!yu{%1P7(vwqPC-^DFVj%Ty1NVaGFfeZH{0p-8b^LdqF4PKO8tAZ?vpPC$ zK=7~cjnxX^Qd*{Wi8r`OZl|k3?$SpC9|5c#4+ih?#7mS{m@EF?j8?l4QI=P0;WXq` z$asGE7;M{S&+%Zt9^fxc95i*IB?pqlo)wSk&K4yq+c->j`?B1Rx;3J-6tIxcWQY9X z_*)_z>~cS19r^S_oV?``c@uRFmuV{!ISq zWPJO_a}b=M^Y{-07h>r({C}KHbs}`8$*L5+cHGt(fIAQppj=wR@>e)q zNcynx;oH~ATd7&bOcNAy$3$4oT?bZTSbyaE737rC)O|0BY+q&$UG-Zv%TXFA`okR9 zQWMIuJ*Q28|L>PXot`y6M7r3-q<_a0+3(C1h>l6WStZZM>+Az%ra~QwuGa{lT52*o+v%<&jx}qT;3XpVUkM2Br;d`gB{psH0w(Y z*vzv&DyJoPF)|-mF3*OkQJ{#PDnN~FDFD|bfBqP)m}0GEll;YPjyEMl;;Cqc?Wd&m ziL{froyuY?4A0A(9O~#p~mzw-pUEj;KkEcWp&;GlKKBG$JZ z4QD z*0KrPwXU?poXO_2(DQGxy%EB62@|n zyXS5H^=k2rQk}2GZjnu4DNFSY3H_7I`7`YO;>YDXwc8#-puyB_RI!!}I34zj3I}BG zLnsdfU@O=?f-l1j=AnDkd8&SJPAeuEY;L*zPaG=YOfGvBd-6`*;<_qrd{Vk_v$c(_ zJ;@JdL%xKLI7|Q_n>7rHAqN1}+H|4rM@vNzhy`h?wp8VokHZDS0=GvhohVnujGR)` zKYiuc5X|@r?*@ttmj64YS;6Y+sW0$Sv{oX(x~-NiPeBgZM|EV~&LCLM5tl6XxvXbI zwmsw8a|2ILnCZoW`YLHW9-Iz`e@@4DK=)nK4xwEyz5)^*@~|-*?Oec#p)peJ>d8ot z5-~S?@!A=E8>{Hwakr{#Vq2G^yJY(OF;kuAMl0X`peohd1Z!Bi=C5>ZpQnkwxuLw8 zpM|7=J#>#FKw*8E2T3Ud?c_Co4MGva7UM7QCQzO3zgf#;9v=JMaq&CbFUPrzOR)k5 zbgTIx7V}-m)SFZ-qM(gLcACG;Xp+ej=a*mqVE-ENi5-QyNdRy zcu)V_icB z#~TviZBt*Fd-i@|6l8XvFU(Uj5KI7uCpispbqSD-l7CCC5;zkPHP;=OiJ=FKFW-R0 zWS={vA;bEco?S$%!dKrmLCUyParhWWOzS0ZbJ1$oW7eY0@@p+U42))V9z6&*&fjwR z7XXgn2Y5Gsu73x%$GkXbXA|@}nty1IZv%O=aa5!FS$%equEoQLZC&&n7lU9nMC89R zfE?!U43J9t3=~3Tu&Bcj697V3K;?p8qtFIBr3RcIolv07Ik?+m24VW zd}T2=i+>(>(k6sFXhxAVK`6%j1$hD{V0o6WLqXq>QHq07*ya!`fJX7AS({=}F<0;E zSq^vel$A&muWs{mfwwK3EHVh{3R(r|j1)&cPa0n((p&hIzmNfe|BRSab|Rm;tS!?k zQfDYnF{1mh{?Om?m(0q*bO_A=#f}(%fjm%(kOyR0+rJMqI1fkRu4{c|>$!tMHF4mX ztpDL<4bj-8lTK+*Qwqxz(9Bxnzhc5xDd$0=B;t^V$3A->`CEQR z{^M^M4ajZ>{ENuM%Qq4U@29(nwgW!i!3lD5JyQc>^f69U5zog+FXc{n4pp^c>yY)* zJXsKg5JMn6bDh$L*&}jp=b@c|K4fYnhQ||q-c4=0BGJnI3`e35BYuTv|FO$swq+;h zd-qa{f?Yz(C~Syt;I6%36dy)~> zh=#3>$KaWEpGUpRXk*UD^|zVs+nfH~F4MJAuaHf!FyZ;O z5Y(hGsVB22@2!e{oPkfMC5eV0hFg~>W7sR*-g?U8Zqk@?n$8I7^Y-isi12wN;JA7wy48*DxeDL-I zDb4H!@Ok~z>qLf(K5uTun0K1OT(E}r_quID%v728K-k=o#*e}-Qz`C_WWLeWv(RWi zA?`1U*e3SN3`1Jg02%hEF)b109gJyd6ou}zK$YqPDsOAf0ps(>5w3+YvFund!O*d6JW&DeDaZI$7V7qp<1%&h7kJUq3V%VDcI@>T!qhYL629KCNzpU9WW7YQhZe(oPcn2~3 zOf~Yb_%6mhKb&d3*>yjA{@|8SC_7N|L~{^RpHp33ROe&;)x8&DsGnIGEeyI0%p=6K zQ#6)`K5FR^6I&giWAc72wjDRGhCQB^OunCOn!IxD0>4lfTuazv899-Zj#D@l92SY# zy@x`on*C;(LyWtE#FY#QkAvS?td9z{E_BYcPp%;W()le<)eC>~(uK-nMu=&J- zBPUr*KhuP8n9=Dib0)V?bM65G-CY7%;Jmg|eY~y1akA>d)^QIh^B<30)ZVa)=WykQHt)&gKdzR9L9cUkt^K-E{B7M=tt^m&XhC~>fOJccYo2`VWJ>s!nDIpETLj&v z5q(Q&7UdSevlG>zJL`Wz3ac!$0v97-hT#gUG)@fRF(9gZ6z21bn>BhJ&nd8+{Os6t zY5|j&>dqF4cLUUBdeZ}xHSiH8|Darll0VI}A=al94yCUBPKkgSg`Zv3Ihop;O`78{ z@j!CgiLtkO?Nql14{k>=cE{FUN6{JagfgIzI0rSRzmWE9nEq^t7q`p)e`0HVSt4`@*9$NS>$Ux;gka?5s>)SE0;r!%q`GM!M zc^Z{QJ5sNjL1-x!A+KBfVN=Kw z%^AE1^jQDvx6*jHxD( z>v!B0Zu$#?-UNh?U<4k-DslHM8bZhIr_+vr=s_#H8DB$wP1do<-MK%}go2E^GP{hKPY1y1n@Hdg6c) z+1kHkb(m_{Xe&c9IHe-dks(4O=u6R8>Cm#a4co4!COVwb=%!u=Bg4VY09ftkfv%AG zgWY;4`6nbWz=n6@m$&B^k4(+n(+pY5%r}A+&qIVii&&0YT%4YsY-7(b6e@pI#?=M6 z8S#Ws^8rS-Y&UgbQVMJMOnLfR-O)%!S19?k1ycv-?^9afkQSSbBinA-kz^?xX)b&Xa9T*r&GvEoS*t*8RwmVSO=s{37PNJ z`JBsOV&|Yikc`A7b7?DdoH3E8xqkLc(gxh9d*=SI$i0+5QDEv9`{?h=W(rg`Py))F zP)toc3p)$~#cKiUXaO51Ct35#pq4CJXxNW@$C&vR_I}g%p5M8>l#YGL<~g&;YAo*S z7D;n&#(1j!F@(gkv3rw=ov@E}j0vUl@%6#3OPQ3mTI2_@rSMWdnCV$ND3OI0Qh7aw zJ%H`P4~KrHJkN)UvdM>wOhn;jw9Ifx0jC6rz4MTj#?JRe3^Ct!Y+-+<&-6PdeO$Sg z9vwX}Vm_Y|?Akv4{mfU#c;#+4*1ozq7gtT!P&tCrWequW9R$2<5@bC3JYB#C{<^uaI zdRgHx0L+Yr2ALEQLle=iL5qG0Y^d)seuI!BA+h5ALPR@v0F~k78AbP& z2nsID*0G0xR&`+Afn(4g$xIUSeHVpU32g@O+R zWK5ivxP(p_clPuu{;jTP~muj+0YqRM2^KKLUrdj9__q zEEIfv;UZvKR!g)8yN=@jhnH52H($v1w-}=S5%KEG#jBB99iLGS1lJ_sG-^U0gl)MY zFp=}z{A)8}>s-6SqRfs`Bl5Yh>Iju(G11=fTJDI<`G#lEYt6--`Fu1;$=N&BfWq>AA{nE~h@;HoVEwU5cuHRKWQvoH2Z0syHgtlQ*u} z_=B~HM4oz0R9kLz(>pQ#6K@w7oFXn~zDl}}PI-&wIebU;T$CbLl~3rG&Dq*msYfwa z{Cf04elTb%NJAIeYz6ZO^#3f0|@fQyU8uztfiWF;jpWQGh z1y6V?(QH*lvyCgKW?lrIui~M5F7oh-k6d6kF0= zqHyV=SM22ULr0OF1T*0@-=#Fiz4vr^p za3_Gzh*Z)t6Ccd+Na=FB8=txO-NR(GMo#Sdh`j8^Qe>s`NsZ*(b{_uw{fbj)Pe6XC z?f}Ve6hlxlqe(}={?L&kUx5k1#cmUJ>lNHc1x1qPOdju;>{4*jO4`ajs4UKnG8`y; zdMw+K`!ocQ-!u##2&TPTbbQt*TvXogrC?_w@YPVB%R*mjo>d7HV)PvtS7dY!hTVB& zYp>$U-14nvJxI!6CYJ$+Z}v?S?hOb%8os^7MeEZ!_tFuasW;|VJnWp-EV;L29HE7c z>N3#l>CwWe^S2%x095!LENWx8V#Psi$UpUz`+-~a@Uls5O6TjP45JC93v&s_mYnl% zFfuBqkI@F9^#0JeaFLpE6`^ZGa~oIWmScB6y&n}XyI}70s_OVD@&RQJ5OM9v-HxA# zd+(F)m(EO1Tz#1NtUj&Fdg7x*oCeJACgWqor{bh<3F4#8I;H6i<-=S|p`5a=JE#gO z4|XN=0@hUFN?o4u;>XDQ*3J-RT&alPvX(>usg{(h%Te_~sWwWwBelkrWmPP!wK@h7^!HE}^zhaAV zsMGE?&-Uvt(@K~7f6luMzg^026{N#EBvRDL(;e|dSA&n4RxO`vFac5U%7<=uE62Rl zxTDiYZZcJdK0QS4Ii4lY0fTnmyPx8>Zt0A&QFb}rB_Q%43G6d9G`{>kL~fKV z{%VT!_J4g$<^k?IyfDwud_dxE*P9egF6A|ujWtDGVSQpE_9U^m{ZQb_kOFPM|DCAB zyV<81(goLz%%5^VH0V)YZ~}_#;s^+?gSP)ZAP!}ed+;2KK`nv8>6QIYlNE=3;#((p zo__cYxwz!(C<;9TPeOcaD?7r|BLP7%>?c7AJ50Sq@NmnqObkT$YJC3uXy<2H8n3%E zeZ=XfK~hrm1-tHGGCv9JXsnMz3pud6Z<};iwOktPy)Z*_=y-f)Ey!uCRShR+i>`pkf0WUw2g?7{-bbwfu7N7vG)}zxOFtHc*fNtALX}Q>5X^svd9N zcwGJ?&w)B!yOAM2tt}|)^7OK^u+J;D1%JmXRKi|`i`LbmwW(+Obz_OZZgZyT&C|A6 zT))n(&M+V_C`Xk(eCdzkgiqfX(=se`>zewa|2ajIDKJRf+x_S42n=>Y0!Op&;R5Ed~JccI~EW+y@p6JA)lKz5tDDZoLy$)A@y4weP zn+Y3R17f$gm|?a*Ln4nBO$7p+rD1$E#Q42b&O(WZ0ON-b%?HHkPxf{{PAx%IQsv%V z#m;lM<*C+Wm@vfD1oUh>`AVO^c|(1G=XVQJQmDys;&%%ZmE!^vLae#dsx&D@*pc8@~exx%iMUU{5DqEXSi&eB-I%FrHuwc z&wjQJmuY!vN~o&}bJuOCtIm7W%TJfZA{YjQNz%h87s$0<%O>ouS26~T=1jhemu%!N ze2VK~64I4gw;ylan%uV_m?bX|dE@p>_-%RxZQAekx6S@YPt4J8W$lLeOOapHI8MP?cyxPX#WRmkp2~9=*V`$l6?(vD9B~=PG+xjDp7u5T zf(ON?)Wb$RK)?Id*p^HWw-%o9BFLINF_dfY@eWU-hvo3w(Ji_1n(eeUp5_X!h+9^f zHAxv}ckm7##c*8b*|5WwpLLiIECoKI-i?8F(fNAnViUk@@W$&$*_Xp=3-$BVMXg@c z-#!gd-s-gaaGBv-PUD%^{u+#K_;*=7dAUplj<44>G}Va`nmd&_qTPKg*g;iwh#VB4DN~sFc4T&Srcb`-qd3ej)hVYnxvd zhm7+y94e zN(1CW+j8~^%I)^1L;Q0T3UJuWd44AYrm8Eb30Gichy`aP4rI%&IoWYl?LfO9d)n6) zw|nJchUD6F`JLy=fL3qxP=rp|Ta0?MCvN`qTE6V4e5LBFo=~Mwz||+jw-HYu<{}&n z9-&a;e0(`tg6wn029vE>w&E#~5;S)gzLahrX(8^XB@CXW-aig-N1^JUKh$z5$&7^a z$3ir16Q^I)e~PkAeBM9t_R7WtzdC(pt7^H{xy9S+1R4s+k3B`igt<_laL4QQ1a5ew z2&2 zfEo@yProBZI|*U|ND3`SVg57{Ye+pulK^mvkfTZoh<#v8SAeERD)}lEGyqpfM;CA# z5d$SWC+E;&@Q!~$AfSI?Jyd%e0aJouIf)8~C#EnoI%C>t#24tI;{k6if_f~IDziHW z2>Ct#>zXXQir&w=`IcXP{rdZ>Z%rM6r&JzWin|{7>(QcEu9*f|;xAys6dcIb6%LNN z?7v>peXe`0%TeBp?}hs9r0YVDKvr}IcL+H&W5;OB2D>X5)!uGw{HX)(s8@y%cokM~(f}JD;tEPsjwxJ6pBNnxC$s7iU`t9`D{q*19H6Z9c5|QNvu3 zSQRgk;wE%Qi}Ai_tca619a0ax{_nuu>Cx74czU?(y5-=?e*LAK&FVI}8ae5#iGt-v z>Ka1q!Sk&HQ%vT>`eZB^!tM6)w+<8=)r!r^dx8fW9B>7(q2JUAsiGKto5hR%Z-iO)T|~ zA5`(t&x{Yv*XBg~f4}oZ=hWw+4T)w`3GrC`J%QO1`W@OlES1~rB1q`zH% zxi0l>@BQYWUQfZ&b2li~g(A=rRT59umpN#A=UmyDIhlLfi6DtR}#&94OzX_EoZnW z6{nzLiZHFNZ?-c1#%;}bSGthV!t$ZU@5tj&2W?&L?IS96HtcaKi%0PLiZ^Qq4oIIM zS3$-}T7Rs8rL2{Up?Y`l?H0&50dWXnXH{j=Ke&tHB!0SInmq`5gfk2lG7>%04 zr)iN6f}kT+1iia@4Z9CyN?eYCO+bUg?O1I6u)3Ljc&d9`(scxG*_t`0Aj_ zwDIQDDPOJe58s8y#HC9N?jT=e81F7d=opzXk+WuRg=&{&J4MoBq#VCu^uS&1=P|V- zINgE^9{^gnzOfUNrfp^tO?7()QEQp}M}qTE=;a>fnKLYrViUiMp;=)*3oYSL9{3%T zak={$%-##u*&58Uxf?!Px&r2A*7YGRq6c$=LDhQV9+{CDg{+_TA>ohK!jBVgyeyfq zwj(3%?i4&tajFfxZTOBZO1l$C31b&hPt|w6K6~IPOq%L%n+p2b5cBqC6ThxNL~pMk zuq3*9?Dlv+R45#_hGr#}qlF2-a$UqGMPHVpMkh2IWlD5Y8tAN?^9$1>lj`YQC>HQG z`2~+z0p08ob)~{vrUIW_)5@Li6$`O23CBign~*SsF4Q6?{mVh>b_ z0Wgf%=*KaFWHqX)8Au2#JXj2s2?>A&H<>=sdWFx9Hfs>pwkYoXCWh!1wTs;%?Hdyb z03<36SHkV>ty2^DK`+0MrF8Lv%#f!#a1rS`Luvy-fP;+uR+fjS-Z z6X#J0fHeAn>J7#hD0UFk!Sse2+_DeGWxNY$@&#WUkPFlgQMsU6=miP7DjixLeBq(s zt2E`dZV_RWeyA0~9R()lGVKU5C+x@($l)+w=aM7UXcXYC9EK}A_n1Z0d`zABA3TZ{ zRr*w|$aVtwe~RQY>fGb>{~LSQtW0`N9uFhT-x>BxK5?VAVZ`ek@6+#a{U)y=SVOJjcG73*>E| z+Wz74JN-#(%<x5Rnnuz9=)1!l{9e zZXxdAx=I&dJlzHGkn5lF;BT$H9-=M$HM`7jiYe%gWX>1!i$Kq}EzEP9srVRT@perg z%^CakGHx)5Tdt(GC(Ez@LEwC@(xP|F!FrWo`>kM!=`JMcP(QTDn_>>{bxoRa6BdSF zJ+_keEvYYGSMoMpcPR7?Du|pz^wClvyfh&OyezI6UR!6bj>T$o zcD4F=X1`IXdRDIFaOMNgE53F`5v)<0cF0aEQ)dpEnW&$&M5-o-SnS;Q6~Nb|$Lh7U z(KD@sj#Qp3)8Rq%y*}#Ym)K{*t|1xp^K<-L`cU}<-Y5Lx9~T0Sk5aZkRvQS>v|5=u zZc#MwB2!-_r8J-ZV!EFCnl4n&ez_GhMQ=&0Pbna5#jEr&Pm$CpvSZAvRX_MA)B_(! zeRyJcg^5W6B5Y2%MwaThx;Ta^(JOSAwVr>G$Kv_MP4AfMZH~bwPdOQz$4vm) zuLHBeaOLZVADVCHke2WKsL_y(PSudZWoxhoPIX5u{Otd*~)V`vVJ@cc3 ztijJpM>7vETOD^p6i+++!SB=%?{afi_a5^AM)g62hFQXt{#*-Tx#P0Kt0iJ7k^1kcwX)NQxhYQJX3M;zEK8mTSylkloB+f?-U|cuX%`C) zL+^$bF?m?)k=7?Jz9dt|^H>-sfPPY{BALZyE0-whMmwlvn6^_N+kBC-tkj5 z7$z_`4H<~f7jXHZfK3^ro%meX5c&3x6b+s~Gg>*KBWK1}wEu>FP{l2 z2O|MUkEWnAQ&X4J^<<7+g=tJ(%FtaRsP`Lw1J(9u_@$!>^m7w?Mr?n9LAK8p>^CL9xzV}8d*nvIl zqoIpI(Y`*p-UV|;{1&_9xVH}j%4`Fm&|P*Eq9PR9F~{mkU06*^>~7T6ebK!p^YP)# z#0HNQ?sI2oj9ND;o>S~1QKdBF#%SGh`iamNbO#Lj;bDj`zsXs^BrfDFw`=G6%h zQUVsOf#?+>1|CNLq?yl*vlIJh#!QKr4XDC4(3rdi)VE;Q!vA+|Z&&;`>X_pn)bZL1 z{i`F}a`E*}7>TtJtViS=bMa%CyOP=J=Q6C9j24qkG(UeS5zH_Ku6&-hj)$*P%7Pyb z{>-?eoC6no?c~(3RKNkbGXY`LWQA;RO?uNzieoYOfkd>G(xXyX+0JSkj*EcPXM(%>U``J%gHTySC9FQl%UMZXZD}HH!}<~ zOt?s{Yn^KyYpr7)tG00RcOn{nTgz#Ram!?9ac2CoK*SzUviO;o!JvEvjAMaeg)W43 z^wHJH!;^!5SQ_x$G~pW1|B}r!^hhW|tG;|`>HsnOktj6o z{BGb8I!~Jf&fqrz_PX-`<9Q1^zJ#9I@5PTarm^F`8Hjw?`)E(>RsWEj%qBi+_mob~ zF$F>xK@Ms8qjpq%0KPQ7LFm&CpI2?MoMH13Au+!aG5tt&37)c-XBtKM^!gLCFiGmr zKoaG72rwjHrl<0PH!QBcP*E3vP($>EIx1k3A{b&i!qJLNVdy(xvFhc%laKgI^-*Sd-aP)3*;dppVxe zN15B>==$vUEi>isQ?ICH30yVIA`OgFR?3dBNF!CpQEY706n(mvsm>JeT8O{ZnRRfB z^%h&Fp!^bJR6DqV!+%3|9{8~WN^Kqd_))^HuwR7F%P}7|`}vTh4!A}2;1)Gg^mekr zw~q&@@pFP+zBY}hDwKuDF~sQXDZs!}kf$HdBC2my0oKYrkc(#_GuI>V<^>*i;Uoq| z05li*DB49lJRRWWtV)iL@s87KunGThnoZ=s=XeutgNos0p5w|C9GB@l89_yjbDvo{Dmq{*kH}A zF820bki9baH6WRU0}vPpcdfb-7Ld<}P?iXYbxlsidtJ1Hy2FBG+{$zZtH9S4f#{;Q zK6apMW`_BAX+c8Na6;qu?K=VH7F;7%G@@y0yn%gSWD-4+#`HHN{uYkq)JK{a9TvR_ zYTZ4=pp$`<1pv>ZbcfTZlUlqw0^UAr5uMb)Ob8P`&fh1${jo{q*<2i@q9?eB1$q!# z0w*L0KO;7RJN^qV^vf9w#pKI^z-bI6P=WL#ycpS(MulAsDB3c?HYCucCEen-jp*%H zZtM{iAO*GK*Jp*(JWxNvR~`HWr};Ce!HjdmPPrs~PT*ht#Id>|K32B2_6xp(TzBOy z#O@lFmR`K=(0!+Akyl2B?UxX6k3sZUG%?5nfxU(x-ty-?O@=Lnc$l4nveDib{eAY1 zpA|;DO+?PP1EQM5>9zJNkQdZ>-)`rHDfvT$dSI9w;u%k*9Yi623viYv*LO-$50#*x z?0y(sc&!GzjQMzX$~^4B1drFnhLDoNBIo%zf5|@m^=G4Xvi8E8J;~Q%&Epg|yh^AW zU+)WWrfEr#;3%5lM?!v}=lVSrddylpZ~zP`ZU6yJ8X!9yLy*M^0@$jiw2Nbi@;X@N z9V;J(_A<}ttJ+?R-kcX@I#QQU_SNMVc0++z=% zXQ8;nK%~bDAJyEo+m-Rl$$ZIE_VMo$b)n6wj%QUFXsMo z{^JYg=#_5Zbvq7Oq=?XZw;F$PON;0pC)BLAS6CM=sYB#EUKO6MI=XGgU++&Zq2IDn zXnQ(*=cMJwrZRW>KYR;GkALwk5;kfmHp%9>>~$+fGEmi3DHuX|+zKhD$w7|=h)vep z9h$nB{itwrmex?-+Mwkb+jGHUtDL2qSN%6Un*h^HNoX5Bp^RvY8v;756&-+cyaV6d zo)`eWVD4n)A79|58|ou!KCdSES!%0)u0`sgX)#gZrenJdDOdDc*8wg|(yvUqAX#tG z_`|S>1O-+SdOX@0%yFm!DrA!jk+aTAp9^d5qa^zs|l0p z*NogkZoT+HdopP!_7>Hwi-0qdmmQg$@u+-XOyZHtDC}MD;WPf?v4+ zWGAlMw)bb!78HUZx})je=LmlI4r28rLe^%v<~aTUWE9bJXCX~qYZWkpuYH`d@hPZ1|8(a|h&Q;J@B5GJ-g#pN**D|7S!E z_0-jXQqt(wu2vn*Pqahb#UL4eu7|1I)0g1F#(rJJXlD)>%l*XVsUG#4;c~?u=R+W= zt};v{E6~z8LK2)7F*QJp*LaSwL>>t%2?CD-p3jgCT%Evvnx27W3D<3953V@Pb+wDA z%x6-1+TLGS*tg3ycjF4Z^JduGWt7EBAFG9v&j?Ml$|wF=_66Atv4DA!1o>bYQV=I9 zQJ{DT#EWR9iS=LpV9d50V)r>km6?4JW>v+MA7X8^nX0Z^q%1PW3k|sy?}Tk%1>6i> zP!5kzgEVGPX*ximu%#%EDq`KIqpHrjT{u^Ot6_;vF2rhNb>V{`h#g|Kd;G>&3S|4tp^q~#JRF=3G%vg6k9pwvcVpOZk zj-(nYCGdgC17Ci|Xg4+ONfPzN__Y>=*BN z2ssW2yCF@l4*^@^3W#cc^~BREVsumk8FEAyDRcT++= zwvkh}FCE+EUQaL}_bmkW6{SrE9gileLP6X@2Y|!W1b>B&2Xex)_Sn`nmZ#R5d27;P zJXYuZ^lzFhL8=v28*G05_gR1xZiBz;tpA2U7ov_yirB-p&ev3=%$huUni=UYGatbY zA(U<#NB%Q4R(0wFBZhk&C~6&Oidjq3f5qoqDYdwEeWm=?uljc;i!ejoC)o4@S0M2t zQi-k*c0N(jP}ZNlo*_!+L^<7B(%Gb-P4Uie^eoMyX{S$iAR18na{6rzP3A3 z-lDT<1C`47Tc}?SAkH_E0z%bw5q(Ox2MLoZW>qgSqJnnkMeRRs zE#@xj!7l-wuU|d}ix+0xAEChnLc`?j+d*Jxr@>$1pL)~u#2fVs1PF`yySIMXge zftge362iq9WEez68z6;pfvC0w-9LftGOwV3wITLoz-${8V3;NB>9)|5>3fhi9i%Wv z3_s?%rf%+6sy65GZrqC+vi2)`TG6s|KZEQ~DK_5ec#u__BEk<1+fms@a47*5%J7 zb`>WLDrGMGl`>?=W){|)ReDGgo(lD0hvc6CwsmI$c8*xT1pK6fZtsiZ3%EcGe^M)q z#>(rhd#Tl-KKye0<}T9>YBoJ5@tCaCCB}Uu;J*ZG)B=Fk3_cBn{_F!%p0z-`azI1{ zQ&fnvDJo&V_jnPEQaolZ2%Sdb{h>H>lzH-C-sh`2*9(p7M8+UPIxpumo3`cCxUIU? zojL=`Z~?pZi)sK7D?%LSN>&L8yMT=Q`)qJ3m7ovKMgF45pTVhBB+`${=RzQ+q1w67 zXl-s%cbhRlIF>a|LPj|D$5JZw3X#{Z7mH77XOF7N@$k1An(Ic3HAz-~x>$B``7x$R zKxQ+3iiu>0I}I_iiCPD)TokVI?^M#kb@vujX{uYwazcO6f_dHdFy-AlwD}*FNMkaq zz{*Yqmwut)23a4jDtu~<4{l93*ZQ(ad+A3)t7TUgnUm~*&mcu(#om4u;o~Oy)E#Hoco`u+i2b$tu6_d0aeQNy#_?6%30Aq@Mj76Ul zT7&Ng*q8}jRfw&1=XUrBi%CqFoPTC4s{&q7xbaF<+ZkB26wdLoSI&Bt}CSJjo07c4mWiFA8^aA*VkNI$+ z@U5J%0d4ign)lE+?HIN7*fq906nRT70unH*Nd1#1_?F@(PglM{J+<#s?H$V>q;51q zpK7|p%2b=@SgcS?Z(aK=)P9WIlo(C;jAUpZPUi7w&gc*jjvpskQIa>ILK;YPe7$&6sAHSIezsn;|@UGbt!N!AKuu^ z7BpskDkU@+li_%zP4?|<0l--qQdUXII;p0r&7P?%->}Q!=W%3Pq5J@28)spjzZ-;p z#Vh_LH~pB13;fdWq+`v|%BqG5CEDUms+ z8|d!?7HqN98oV+TL%|NJRhi@^b-Y-cj+BrO66>Bm^1fzl$r{r}t)Q+%!51HWXCxm~ zNj{ytT|@U2;SuTv$>gcF`pX#<)Wh~dKaOMMyqw{p@hQi<2bFmD1=yuE{aYoNbt*|# z6aUE`+8@G#pW6a$iQ8|F(E~7SKEW68JE8!*_3u-LS`zyvi-=`dd*#|OyX@MU)*;W* z)RN~3Lw6Lh-^jHTbieA7VTD@cLf$J~{#7GYT9QK$;*0wtH6OHOF{zbD8yF=fRG)DU zC8_}bHfQ5^4G>HXlI@CDKKJxo?UIop1$u;}-N4_yl zx*W^K6RX}S*fM^qdQX`RdVb&Fly{*jC|$EAaRSJ6dFxej^M$FuFk*E-4_)$lXPk8M zW~~Ykq)UdEYX15?__9O6?da&TiUrO1e)GGOHvBzoczYjCparcvNxSmd%FLT~V`Nsv zOgUDq_&KLj2P^650UK|_={GN7U3~bbQ zDw(dTD}JtrNzI$o+buA97cPz`qMv%?UxdMht8Q&&lg<@ET>nc8%LfXWFNf$kHc1Ks&^^- zZGIspbukv<6(V8!!PQw-EbO0yvt#8Xzp&Am^Qd+I;0#RY+_peHDx;B^$ z`?Y}M;o7@p|1Y;5i}iYtj;8%ZvXx$r4t0Hl7oRt^H-}CRw(%cKi=_Gdh4{o(bX_chLxGh2a>jP(=mxf0>eblK`Y&72L`|XSH zC%~pFQ2zTsC{_O#3z4hD8)ST*C4J|4T~Kv5f-g+d)}5+0K$OIgMGE{2MNY5@dY#d% z(JdD3a$y^?M*CS+=qGj)=_f&^Bx~|6%s>YH-wy~}6K{qI2{k=Uu7FbYaWdMtPZgU5 z&2c#W!muuqgg){*=gA9XYJbG_#W`vEvYaqmS1oq1w?|by1z=Js_0ab1<7a((rrPsv z@-L(0aH;cV-2e$c^S~o|vjAghUR<}9*oB}|(8QpattSsdB_>>^%wGQ#ZF9=ukDXMT zbAWI8f9%>)Ig>xkKjV<$w+}|f?7=m}hFqd6G)_MzUT_nB*X&uu7iJi>N(~u!>==B$Q5s-{7evTaFx?Ib5)%}kFv}5oQ z!I7MPeCxw|@y@O4H)K7{(J>u;o;228mq|)~sm<>plVRZFg3EKAT|UKMvFCt)%>Jtq z-w1zA!rqB%uw{l>f~pfHbgzHtNVhj-w!pX8x=Jn$=_)*PI+A=X<_51I7Wg{i z)P9f5JUozc8>tVNs*~&1=$|mQ@=rn<`8;K#7cI9kDV7s-*tnb&?_IyLzdoM2n>0sJ z=WFGySdFdBzwx2lGp5;ygM#g;4DW2c^nndtZ(&n?3s8a#(y6=>ho0eXLU*1%bV&wkImqRg)r3>5Q|#b)QZeeY?lr$Jlb+b5W&f<6#LK zX9UhT-cX=Jgk}zp!n~>d@@qi~^+RozKejSNv^&mrAfHlsfQ?sX9e~)eJ@kh9w>c=o z`ms6))pM^|c&1Y`;=PJ7zZ@Jpj1|c|z#X;+clckHHV7}(0y}Q-W-oubrZfB=wi?v5 z6Xp$@XHJ-MDltLLG`9O{abZdJ#{Nyt`QIgYp$RSkHv3MU@B*;@NBXN8)WhWtyl~MI zP@-C2(y8wN2u|p-L5cxHC5jNQ2qiXyyzQ*kxV{Or`&rcMMjMz72Fx!!Ok{uWF22bo z#NEYEHOOLk;tXf_Z*c|^_*1_Y1ny)Jdc0~ySVktUufxW})$0NVT8|6Am+Bd@UJ1S{ zS|9KE)|(d4`7-}EaBtM-Fvp2nit(r=HtqZk5rhzyK=Mwymx~c0#Gxn<+O6RB`#gaRJ|PqLN#EfTW7N8v; z^ESd*L@E&PymBL4FR*5hy2SE{eUTddJoG%cUCD5pjdXC4tKkwduYq~euxcZE@hVqc z??|5m&i{Qf6Q6RzKAR33cgV26jQZJ~3L4Kj4a=7$-LUHTZCrmMEXXsr4R2nBFIr?ulaB01x?qgd`1N(n6%Ih-+~I-qF^$;t zdWw$}PN7V()jBjx@8cFHL~f8`O&FXAPWyIxmcDs-NKG-y$}UOzGUlSpG{#;2+c{rl z;Z*lFpJ0R)G_-E3-zI2C{Vo6!ZSM)R5RV@c+>RmNjM~f8^WPJ;Da7!#<6V1 z(uCu5CUqiPwZ678c7HCsr=+Zii#-}7ck&yCGoBCY^EK_Jdw3d2?pv@i7czU*o*6Hq zisth-sNb*Y9U`A*O3Pp;^dg6l?_#p8B^1O{pG(Q^9@hKAJ^2} zKy`jto=UZwq-sY{m*^3%s>Cag4olwLw7LG`N!;U>#tizf3izZ!N7>x8hMA+e&D*U< z+P=xq9^)2AHkkgVA34DjryJ^b^`)gBuM|45CfHOZiuuDAlbk9CSx&~VBe(k=ORr<~ zxYTuYSg%B7gzcqIG_aZDM=>dPF73|V}eTUw88R#12=`p~&O#!>*MuY+e+?|m>Grh`oiF;|;zO^Ty% z$PVhJd414N>*K8qb@SoHmmwBlfydinlAmOj>IZ*1U;j{fA^q0XD>5BDF^+RT8@$i@ z?^nAVdE30rnBKM)n)XpXNrMQaDXycy=-F2G=_jSD**d%O(>e1&FQ~-QOTQ5s>H=i8 z;WxD@(9p7~_U()KmJaAR_qEGtb>~Z6G(+t?qD)U)O=xcOd~Xo-8|1z@)Np03Ml~d| z=yO{7jmo11J$;pDJz&`*7#SBiPz%;m5*zR0v>R%(l~?P|eorn&B2dLg-(C%m`mRCI zw$1z@%cBRbw#M~vS-q2YJZg_*5|miVJEDpz46G_--)Uk6ux<|MM@F_ve?hQ zt#rz~^?on!fNiiHG>mm;s~Q*CtZHY1A#Cb{~gP_v+|4JkEnPCkuwkvx1CC6#6~y}mRz?#1>1 zbovEEQE{eKcdm_;f4>E)!;Eddum5N&K#-!RnLY>vcDHe8js3o87sB18GFDwch)S>* zZjW<#PqK!f-dj5YwWbkp1KF(XIqkYG7jXQ!fj3mtH^h{WK{W(~JzCyB?;4IL1Mhkm zT4vu`q_rRtY(Hlhd=-(~TT+Lk{bJG><95Kyx>)3M2@dyb)$UH?z)2Kw6h1Y&%h(-L zc(>BQdy#iHgFfesb8wb#;EEVplK+4?Wx-3g(pAmmuv`7d+=?OrUfeE zqQO#xKkoYOf4OU*+%*-(X7CvMzK7%EQ-i-OsNM2;ct{jKOgio!`O>2^)?HCbVQ6%27^0U3=adlkR6O3MpN@RB#uvgyPS5ftk z={G!SkhnMMPo*uhMpkAq>KEdEbZw^Urui5L7$CxCMb)!BKCT@;W*!ZAk@M!+fGOpb zL$JwW4?*iUoF*M&RAQ?G*5N=UsEXQZT8*?QXvpuZ@=kl{mimprF1sQ_c7E&Ihg@Xc z+Gfo4PJXU5M!|76O?Dp{;S&1#$Jkp;fOMkOwFmbf-0an$flxeyKOjzh2RE2XAUJnt zu1hLTAh~>V1|>6447MCF|CiB9{qy+I2nvN)(oqfEzf(4+smOY#784kjHF%l%yhtM! zD{Xe)qvxQdbz*n0TK6xh;L6nH{kv73ceg?_StUr;&yduARVn`S=l{V@Tm{*}{{=gd z@>g+0ab`j=CGVB6Wj?&^{D;>T@y+a{@)vs_r!(@UymBL1B$p z0EbkSAOt%HF8LX=O<)1%rWI}U|KMdtnmOf{-F}m0i#Acy7I0cKKlg$N>R*TaCn`e$ zR2Gf@Z&bGYe;buiFBgL?I8r-nHlL8xmd^|ytpL79^EC@orSnB#a^wMx&EivGoUj)ap=mUTW1P@{5{SP)%b@T;H)0^2i`AW`gS7YP(m zi16bq%q{`2vvkwX&TD64b+%r0-o90Y#3Kc?`82U_BDsTHGBo*-6K4LsS&@sct}I^h ziaT+Ci;2>K~`mj!Uf0!8^Ce1s5PYV}DoTR&3X6 z9Tdn&tNHvX>V3L^mT};G$LXaPpiyBCdJfFSRVU8@?x#ZodKw@Q4TrAJKZKl;;yD(P zWFAPZ`f{w!#}sh`)F;W1iQ!9u?^a%kWpkPpdlcKZ!pqTt<+gUaT2TWpMla9wBCIOZ zdhJw=a#AmnboR@3aO!>}0>Yt;M>MexbO=?bkG5X|6`7*dQ}=jaxDq6X6z{-l8%lgt zYh}t!x8tbKj&8Gt7maz&ds2}+6YMy@N~(z85*+9d2{k^4Z9R~)k0^hQwww0svN?FU zayx&~bC;4-hBxZ5(4yA+xt-?(4#0T$M+ge>M+geF!A4IE;)l3`560?8a}!reka_v> z_SHX6%Ge7#vCxn`Bq=0prd~ekk{{>I@v$|g^D~>)L&6W_5W+uwM^ynlRbwGSAH=YlCmN)Z5KUyXqt_MiiQ)UY2okki$|TNlx=X*QM6u6LAKJ`U>v!my;X^Iojvo zqaQr42|zy`-saUi_b@;$=U(u$z$PwQ6L3aK!NOLmSx4e*YD-Er6gJ)i~+ zoFO42E{H|YFhM%A`hMj|`6pI=70NF%nUQz+<{coAl%w#c_%^Jy=G_%lyjPXoj<{sg zh%m}aUDnP(VShEv<`*JNo4V$A>?_yVW#Ac0iAWh-ZwJ2vonn;$d?-E0MlqL5RK-e@pnX^R>}Y z4$aCtBv&HaViHMHUwa+vj)Who4x@xL{qj_aMM?qw(0^&ioJkE*eD=}7*J9>^krw2| zRl2(9dtQ^1*Kd{VNLMaE*jPE2cB6vpPKfMP&9FT(G-^8(fnWB_-2@%nf8S5L;}6p+ zH+rUya}O@sr&UrzmVds>^SpG~=K?F;S>S9PGZ_y2i(xB$+o)JGuEYcjdvgnJ zw>$Q%kt~2)C&O&_kCWNa`T*sw6Q0*%kc#xA-9O#1rs;W;cb{*EIsEqOd2XT4BM56a zxzXIzRA278RBShf-P0^0+a$ei+{r;P$Y=RP4bf$b(>isJKFirXiy(eqtfkoDp)ToV zYf_adQJ)-`3Hgh9W-$)k4jzi3h4gqGV=_>JkZ+m8i-d#q<3;JzptD^B(pz*Y>=;sH zH%gM^c`E@EO|LHTWln2Y0xx#=I^eLYDpRm_SRX~LF48w8)+DE6CVMsFL5gKQNg$kV z8GzM&H3V1CEQBr0oltZU3U*Wcm3Hx5xJ!*n*J>L^6xhCe4RnDdZ#MKWk-Ku*aqC@K zR6JU#zhH>953X1raxb=If1KsSpu%5Mt`4^pAiYV|A+UQk25yEr0xNKucK?P*Ab$X^ z{czm{j3Upm&$aPYges6THZYQ+zkPztyy7R~zust~lIENQP=254Y?t?RGbmq(9cgOMD zpsBL#V?Z|i05hIJU;T{Zw%d_6qQ3S*w6V~xGxI)KI%rzYow`Af9Uel~qc`<1fJ}1R z$eVcD0(!4PLrP2pJ-}FO&8gF5HJmb<+&QRv_~mZutM=hLY%=TM$n2J*htf+#2+>}D zLrwz`2j8?Q#`gg1@kF(0MHqA8?cHrG3ixMc_M1HeW`~}a3S1Gly8=1xx%Gjx>^aBYhi@~L1z?bWO2bm*i zt5YAPg+Y$Zi0nPO!2FEC9XbamfQ<70m9G*SXb>jYp&NL{AAIezC;ya_PtRXRW6Z#yrzW_Jf@VqF?Uuaj}o_r<>gD>n8m4gUGs-&yL@(5|M@6O zZS1B<@5^1XqGpXU*><=1h&ha`yV(v3N8L`(=p&XXiE8da?(f2ww3<1^7YZ z)~w#dc;bR%dl(ADzZGklKp<}C>L2?npyv;pxp*;gvpr_6iu?Psj9b@890Fr9Rk=L- z9MbqslN1!cttTb@l%vd4_TolfgP^WHxR798iT^ytFBX5ZF)nXT)G%0QX2iy0B27GrQ2CH}WVk>GEME z3w77Zy(=z`-QQ_)DSj-VguxYIW>Zty*rZlUot0P2b=rN@_VQ+K>ZRTCmr9&e4^?v@ znn>aRz>8mg+Fv4Gn(~#t2;Gyl2yuaKeD0;^CZKh(&hzL1g5p9+p_QLNNUQYE4>i{p zQpBzbL@x1iVjaXgBym2wJ&xji@^x|5H@8+DO+ES2_4lGDD%U)eBs^?QQq&Ty(qzwx z8m%vvOgfIfrlZe+`10b)Fcg74S_^6w^%kv9JiLg%rMh1y-oE8Vrrjt{_rfhO+P))b z1ZDxG$xGLCVxvP74?nFuIFZn0p!jrC=|Rh-_(2nklwJGDuUiX<$jLo%t(&n^Ipq#n z)jX0?xRMuK!7FC(Wul3|e1`&*3{D@8Y|M{r2ybjGG8!1PesA@UuV8{u zDEYq#Yr@j(8hKo5sbUeU8|nEv1@cSLH*TUg&bn)}Re9%%3v#EdYm8<;BN$Tpw^#x! zyLi2B3${dSYx-=$Ha=(8?UWnyjhaQK{-t%5!+P~y>Tb0D@#8Sa4)p!pxpJ)#uf3F3 z-9x#AzafccK`Yt;40mXhDdJQ)_g2dMc!>Qe^btPKT#HrnXMUF%aqV_6y&7BEBBY7` zHpT#Pp7ktZ_bhkWm~gRuMeX|f%T)O(iH8B&u=n3(Nq@udzl*x@KlDJF&swdMbO|oD z6^izc%6>pZ!xpw=<|r~-7_5M{w543mVC9Hp{jHs)fcrX!j-5+%xzy^&e*4fv=UI=F z)@MdqH-|spbrs>gEOYTTslXsa5EgQ%d)ZiK@LG9_+BKFze_Y8^5(j$TaUd7+mf(he zg|TOkggwL2m8LL{p^n97>p}~7xO#c7cCz&^hz=On*M$?A1X>1;Z~R0Y66ZjPu5e(= zbPLmPnct+El<$TB8*jQ-lFrZadM-0t_|wwwdg_#R zxi14_(Hx-zPvB{Dq_?(&m2P=FDS89Og7II>IGArnG>WCVp66sVO4@XFmq4-Jx{_%1 zI{1Rx{U>E{CCg53Q4<0{fAPMO7`AmdTN@-=ak?gXAAA{aYC%Y9;|<1+^rOy#_=O*=WQVeh=a#m%;08mG#?Vcpr1cct{c6;oVMrOHR{9-6zJzsvEDq|5=lXT))7J0Tp}kH| z$Lj3Z)f5u6Zqs8rW!aEfzcQ$w^}YIHWql|7i1sj-8g^33D;(s z0J=)Q2jT`Eh5v>XnF?N8P!wu*>yik!l|YN|-3iq-XWZm-;j??|_4*jy1^3UOAEnjl z&;sQ8;+w^8M!!?uld-lR>G8fAd+By%i4lHlngGH3Bwa)jZhm_Cz17oJvf)`;$SP-8 z3D*7DDDUZCb+&x&RYLr!hoV#8V^)}VzS!m=-rK#A;Tk_i_xL{6;v1xxfuAEXduTOH z25l8(tPiap=J-S_$v#OWQ%GnQWs~9Z6&l^wVzf-rY@S-HY5!y{f8CL*Z&;;Goma+` zP6mkcwEqn$8JlTEFlSe)FB^Ck@{8T)406hGi<9Ggy|`}d4AucJtb^#Dw&iIN>uT^Z zP^~TWenP@?i|^WJn`nV@Z=u37%&E$bye7+TG)?W$gQ`TI3#19~OZ;FRCuN#>R{0|<=OBtVwD zA^f!J)+!jL3u$rtVx`n~pvSjU#Z`N_=q6{u^$P;VSSWxZa+quS>EDnl5R=w_aoC4& zbAjK#0zx5=9BY3$-DQfsdb9VXOpJ6fmSY5q?AI3Bj-IdspJ=`z^iW(wTxfv3JPK^q zeMxry0AfNnO45XAxdxgNd*W+QJRJaDNmzFbc{H|jPRra`asPCj5-kqNF=LfK7bO?5 z`rG*9^$Z$c)i!qWWVeGFP}I^l%plABQ5z-A#>V#a&Tp*|1@+7|G|Xanzewz0(aK(M za!rmhQjTv?4Qg*Zhzz3>PUOObKF+$rZ*GWNbWnyYRDV!jU;~r^Mx?UdrsA44UZftT^@)o<= ztOC7os5a?E^#cfJoYMDywR_H5K7VLtwF@1MB3wpC}lTEMbrfg~CnKS0CZe=C%Eke9v<0vE# zLC13!y{U@LZ8esy8xwXW_7Unnds+?=w|93^S^0p`h}f0`D|yV9G| zSVWuBZJh1bdW?&V^EV-q}*c7P_yCL6CwRpt>vg92E z?34a~j2L|;MLalHhG;HwZc1auvE%=0v+^$b)+!vE_{=VkPrJAKVVqC113iS!NsSu^ z;1lyVVn2#;(^cq`x8h~_b&u&nchOnpaKQtUVob0vW%24ftZ|#|7lLtb)4nzFiItm| zqd4tIoA{%)hnr70I9>Z6!`P2^fFi5>K`34T;BH2OYAudFSeR#Y)0HWr&eRg)?G_~w zP0?$@%20jYJAj--$=>2b!AB!)i}mXuZ)KF~3#;sgDj`;Z#H)rSq)14*vdx+4$hYWQ z9EgOt)Zgq`6N`he2+7Kuh={M%Z#uxPs1?Zkv08_kWSrejt@6<6XTH5pY{>X%SrSe=`@i zKjtC}ecy07y$MdY2dAGkeBkJ8!QmNqpYm@1#uCkrvT#JI1zY29BUj+l;1uiWWl62_ z!p3V;kB75H3m2WP)<&&Sw)RX^)&@UYqraDWQ5NR$L>bCK;K0)_`bsZIMSk-L(Tdg> z**i+y%=QlbD#mmrbj-}j$@eh?a@eRSu*r|LXiF2sb)pF(HkWf~dv7qse8>vITo;Y6 z_c!saUhi<;=LfdKkJ{M9NvSWYVt4KqHa&_)*u5Z8e9R|6I(}&r?tmA1fS~iZ_h9IC zvW2fwztEcmT}!7>rWkpbqHotB-?*=i^dmE@^(~XSr4xszjy}1+oegr1!Vf$I^8P;b zz9;g8hgEfHtP32J3HiVqtLBf=6( zF<;mglXER17aMoRH%Z;pl^RLoRDVAI)JpM4|9aF+Hks_m{mr z*BT#|F=P}EI;eFH$U>lC&3U@g{~TYp1|mptVHhMhQL1?QA`cBvtEbh~-?eZc->;Q;%7l2$;z<8S=abwxeBj6 zN@BdL-TCj1D=*umc&z^~atpJD5s7;9NZ+Vf||nwqjM$yi*~%4DNhhu;A<0ztGW`8)M$8H+Gi4)4($_$k&84%oJWlV)SHYwWN!?-s>xePo`?l+)ae(lvOwXex{33z{e zBfR{>BrI=fIkf#TWa$K2aXka#aK?1_|Km65-xGc_&!`~aE0B6(sFI)cSbI}!Wd4ws G{eJ->cK8MW From ca37f221a8ccfc97412eff1d57ab2588beb85dad Mon Sep 17 00:00:00 2001 From: "Chendi.Xue" Date: Tue, 10 Mar 2026 23:19:36 -0500 Subject: [PATCH 02/38] update GpuMemoryMonitor to DeviceMemoryMonitor for all HW (#1526) Signed-off-by: Chendi Xue Signed-off-by: Chendi.Xue Co-authored-by: SYLAR <125541396+lishunyang12@users.noreply.github.com> --- .../test_diffusion_cpu_offload.py | 12 ++++++------ .../test_diffusion_layerwise_offload.py | 12 +++++------- .../offline_inference/test_zimage_parallelism.py | 16 ++++++++-------- tests/utils.py | 14 ++++++++------ 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/tests/e2e/offline_inference/test_diffusion_cpu_offload.py b/tests/e2e/offline_inference/test_diffusion_cpu_offload.py index 6e5f449c1..b4639a35f 100644 --- a/tests/e2e/offline_inference/test_diffusion_cpu_offload.py +++ b/tests/e2e/offline_inference/test_diffusion_cpu_offload.py @@ -6,7 +6,7 @@ import pytest import torch from vllm.distributed.parallel_state import cleanup_dist_env_and_memory -from tests.utils import GPUMemoryMonitor, hardware_test +from tests.utils import DeviceMemoryMonitor, hardware_test from vllm_omni.inputs.data import OmniDiffusionSamplingParams from vllm_omni.platforms import current_omni_platform @@ -23,9 +23,9 @@ models = ["riverclouds/qwen_image_random"] def inference(model_name: str, offload: bool = True): gc.collect() current_omni_platform.empty_cache() - device_index = torch.cuda.current_device() - torch.cuda.reset_peak_memory_stats(device=device_index) - monitor = GPUMemoryMonitor(device_index=device_index, interval=0.02) + device_index = current_omni_platform.current_device() + current_omni_platform.reset_peak_memory_stats() + monitor = DeviceMemoryMonitor(device_index=device_index, interval=0.02) monitor.start() m = Omni( model=model_name, @@ -33,7 +33,7 @@ def inference(model_name: str, offload: bool = True): # cache_backend="cache_dit", enable_cpu_offload=offload, ) - torch.cuda.reset_peak_memory_stats(device=device_index) + current_omni_platform.reset_peak_memory_stats() height = 256 width = 256 @@ -44,7 +44,7 @@ def inference(model_name: str, offload: bool = True): width=width, num_inference_steps=9, guidance_scale=0.0, - generator=torch.Generator("cuda").manual_seed(42), + generator=torch.Generator(device=current_omni_platform.device_type).manual_seed(42), ), ) peak = monitor.peak_used_mb diff --git a/tests/e2e/offline_inference/test_diffusion_layerwise_offload.py b/tests/e2e/offline_inference/test_diffusion_layerwise_offload.py index beac14099..6132f1bd0 100644 --- a/tests/e2e/offline_inference/test_diffusion_layerwise_offload.py +++ b/tests/e2e/offline_inference/test_diffusion_layerwise_offload.py @@ -5,7 +5,7 @@ import pytest import torch from vllm.distributed.parallel_state import cleanup_dist_env_and_memory -from tests.utils import GPUMemoryMonitor +from tests.utils import DeviceMemoryMonitor from vllm_omni.inputs.data import OmniDiffusionSamplingParams from vllm_omni.platforms import current_omni_platform @@ -28,11 +28,9 @@ def run_inference( layerwise_offload: bool = False, num_inference_steps: int = 3, ) -> float: - # For now, only support on GPU, so apply torch.cuda operations here - # NPU / ROCm platforms are expected to be detected and skipped this test function current_omni_platform.empty_cache() - device_index = torch.cuda.current_device() - monitor = GPUMemoryMonitor(device_index=device_index, interval=0.02) + device_index = current_omni_platform.current_device() + monitor = DeviceMemoryMonitor(device_index=device_index, interval=0.02) monitor.start() m = Omni( @@ -44,7 +42,7 @@ def run_inference( flow_shift=5.0, ) - torch.cuda.reset_peak_memory_stats(device=device_index) + current_omni_platform.reset_peak_memory_stats() # Refer to tests/e2e/offline_inference/test_t2v_model.py # Use minimal settings for testing @@ -57,7 +55,7 @@ def run_inference( OmniDiffusionSamplingParams( height=height, width=width, - generator=torch.Generator("cuda").manual_seed(42), + generator=torch.Generator(device=current_omni_platform.device_type).manual_seed(42), guidance_scale=1.0, num_inference_steps=num_inference_steps, num_frames=num_frames, diff --git a/tests/e2e/offline_inference/test_zimage_parallelism.py b/tests/e2e/offline_inference/test_zimage_parallelism.py index c43a5d224..47de955aa 100644 --- a/tests/e2e/offline_inference/test_zimage_parallelism.py +++ b/tests/e2e/offline_inference/test_zimage_parallelism.py @@ -22,7 +22,7 @@ import torch from PIL import Image from vllm.distributed.parallel_state import cleanup_dist_env_and_memory -from tests.utils import GPUMemoryMonitor, hardware_test +from tests.utils import DeviceMemoryMonitor, hardware_test from vllm_omni import Omni from vllm_omni.diffusion.data import DiffusionParallelConfig from vllm_omni.inputs.data import OmniDiffusionSamplingParams @@ -93,9 +93,9 @@ def _run_zimage_generate( if num_requests < 2: raise ValueError("num_requests must be >= 2 (1 warmup + >=1 timed)") - torch.cuda.empty_cache() - device_index = torch.cuda.current_device() - monitor = GPUMemoryMonitor(device_index=device_index, interval=0.02) + current_omni_platform.empty_cache() + device_index = current_omni_platform.current_device() + monitor = DeviceMemoryMonitor(device_index=device_index, interval=0.02) monitor.start() m = Omni( model=_get_zimage_model(), @@ -161,8 +161,8 @@ def _run_zimage_generate( def test_zimage_tensor_parallel_tp2(tmp_path: Path): if current_omni_platform.is_npu() or current_omni_platform.is_rocm(): pytest.skip("Z-Image TP e2e test is only supported on CUDA for now.") - if not torch.cuda.is_available() or torch.cuda.device_count() < 2: - pytest.skip("Z-Image TP=2 requires >= 2 CUDA devices.") + if not current_omni_platform.is_available() or current_omni_platform.device_count() < 2: + pytest.skip("Z-Image TP=2 requires >= 2 devices.") enforce_eager = False @@ -223,8 +223,8 @@ def test_zimage_tensor_parallel_tp2(tmp_path: Path): def test_zimage_vae_patch_parallel_tp2(tmp_path: Path): if current_omni_platform.is_npu() or current_omni_platform.is_rocm(): pytest.skip("Z-Image VAE patch parallel e2e test is only supported on CUDA for now.") - if not torch.cuda.is_available() or torch.cuda.device_count() < 2: - pytest.skip("Z-Image VAE patch parallel TP=2 requires >= 2 CUDA devices.") + if not current_omni_platform.is_available() or current_omni_platform.device_count() < 2: + pytest.skip("Z-Image VAE patch parallel TP=2 requires >= 2 devices.") enforce_eager = False diff --git a/tests/utils.py b/tests/utils.py index 3219821b6..e4e3e14c5 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -20,6 +20,8 @@ from typing_extensions import ParamSpec from vllm.platforms import current_platform from vllm.utils.torch_utils import cuda_device_count_stateless +from vllm_omni.platforms import current_omni_platform + _P = ParamSpec("_P") if current_platform.is_rocm(): @@ -504,8 +506,8 @@ def hardware_test(*, res: dict[str, str], num_cards: int | dict[str, int] = 1): return wrapper -class GPUMemoryMonitor: - """Poll global device memory usage via CUDA APIs.""" +class DeviceMemoryMonitor: + """Poll global device memory usage.""" def __init__(self, device_index: int, interval: float = 0.05): self.device_index = device_index @@ -518,8 +520,8 @@ class GPUMemoryMonitor: def monitor_loop() -> None: while not self._stop_event.is_set(): try: - with torch.cuda.device(self.device_index): - free_bytes, total_bytes = torch.cuda.mem_get_info() + with current_omni_platform.device(self.device_index): + free_bytes, total_bytes = current_omni_platform.mem_get_info() used_mb = (total_bytes - free_bytes) / (1024**2) self._peak_used_mb = max(self._peak_used_mb, used_mb) except Exception: @@ -537,8 +539,8 @@ class GPUMemoryMonitor: @property def peak_used_mb(self) -> float: - fallback_alloc = torch.cuda.max_memory_allocated(device=self.device_index) / (1024**2) - fallback_reserved = torch.cuda.max_memory_reserved(device=self.device_index) / (1024**2) + fallback_alloc = current_omni_platform.max_memory_allocated(device=self.device_index) / (1024**2) + fallback_reserved = current_omni_platform.max_memory_reserved(device=self.device_index) / (1024**2) return max(self._peak_used_mb, fallback_alloc, fallback_reserved) def __del__(self): From 617b1d6053f288d81af62c540413c614a00c054e Mon Sep 17 00:00:00 2001 From: NumberWan Date: Wed, 11 Mar 2026 14:36:48 +0800 Subject: [PATCH 03/38] Add coordinator module and corresponding unit test (#1465) Signed-off-by: Jeff Wan Signed-off-by: NumberWan --- .../omni_coordinator/test_load_balancer.py | 58 +++ .../test_omni_coord_client_for_hub.py | 119 +++++++ .../test_omni_coord_client_for_stage.py | 110 ++++++ .../omni_coordinator/test_omni_coordinator.py | 209 +++++++++++ .../distributed/omni_coordinator/__init__.py | 27 ++ .../omni_coordinator/load_balancer.py | 82 +++++ .../distributed/omni_coordinator/messages.py | 61 ++++ .../omni_coord_client_for_hub.py | 164 +++++++++ .../omni_coord_client_for_stage.py | 193 ++++++++++ .../omni_coordinator/omni_coordinator.py | 330 ++++++++++++++++++ 10 files changed, 1353 insertions(+) create mode 100644 tests/distributed/omni_coordinator/test_load_balancer.py create mode 100644 tests/distributed/omni_coordinator/test_omni_coord_client_for_hub.py create mode 100644 tests/distributed/omni_coordinator/test_omni_coord_client_for_stage.py create mode 100644 tests/distributed/omni_coordinator/test_omni_coordinator.py create mode 100644 vllm_omni/distributed/omni_coordinator/__init__.py create mode 100644 vllm_omni/distributed/omni_coordinator/load_balancer.py create mode 100644 vllm_omni/distributed/omni_coordinator/messages.py create mode 100644 vllm_omni/distributed/omni_coordinator/omni_coord_client_for_hub.py create mode 100644 vllm_omni/distributed/omni_coordinator/omni_coord_client_for_stage.py create mode 100644 vllm_omni/distributed/omni_coordinator/omni_coordinator.py diff --git a/tests/distributed/omni_coordinator/test_load_balancer.py b/tests/distributed/omni_coordinator/test_load_balancer.py new file mode 100644 index 000000000..c54d24894 --- /dev/null +++ b/tests/distributed/omni_coordinator/test_load_balancer.py @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from time import time + +from vllm_omni.distributed.omni_coordinator import ( + InstanceInfo, + RandomBalancer, + StageStatus, +) + + +def test_load_balancer_select_returns_valid_index(): + """Verify RandomBalancer.select() returns a valid index for instances.""" + # Task structure mirrors async_omni; RandomBalancer ignores task contents. + task: dict = { + "request_id": "test", + "engine_inputs": None, + "sampling_params": None, + } + + now = time() + instances = [ + InstanceInfo( + input_addr="tcp://host:10001", + output_addr="tcp://host:10001-out", + stage_id=0, + status=StageStatus.UP, + queue_length=0, + last_heartbeat=now, + registered_at=now, + ), + InstanceInfo( + input_addr="tcp://host:10002", + output_addr="tcp://host:10002-out", + stage_id=0, + status=StageStatus.UP, + queue_length=1, + last_heartbeat=now, + registered_at=now, + ), + InstanceInfo( + input_addr="tcp://host:10003", + output_addr="tcp://host:10003-out", + stage_id=1, + status=StageStatus.UP, + queue_length=2, + last_heartbeat=now, + registered_at=now, + ), + ] + + balancer = RandomBalancer() + + index = balancer.select(task, instances) + + assert isinstance(index, int) + assert 0 <= index < len(instances) diff --git a/tests/distributed/omni_coordinator/test_omni_coord_client_for_hub.py b/tests/distributed/omni_coordinator/test_omni_coord_client_for_hub.py new file mode 100644 index 000000000..24b331923 --- /dev/null +++ b/tests/distributed/omni_coordinator/test_omni_coord_client_for_hub.py @@ -0,0 +1,119 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import json +import time + +import pytest +import zmq + +from vllm_omni.distributed.omni_coordinator import ( + InstanceList, + OmniCoordClientForHub, +) + + +def _bind_pub() -> tuple[zmq.Context, zmq.Socket, str]: + ctx = zmq.Context.instance() + pub = ctx.socket(zmq.PUB) + pub.bind("tcp://127.0.0.1:*") + endpoint = pub.getsockopt(zmq.LAST_ENDPOINT).decode("ascii") + return ctx, pub, endpoint + + +def _wait_for_condition(cond, timeout: float = 2.0, interval: float = 0.01) -> bool: + start = time.time() + while time.time() - start < timeout: + if cond(): + return True + time.sleep(interval) + return False + + +def test_hub_client_caches_instance_list_from_pub(): + """Verify OmniCoordClientForHub receives instance list updates from OmniCoordinator and caches for get_instance_list().""" + ctx, pub, endpoint = _bind_pub() + + client = OmniCoordClientForHub(endpoint) + # ZMQ PUB/SUB slow-joiner: allow SUB to finish connecting before first send + time.sleep(0.2) + + now = time.time() + instances_payload = [ + { + "input_addr": "tcp://stage:10001", + "output_addr": "tcp://stage:10001-out", + "stage_id": 0, + "status": "up", + "queue_length": 0, + "last_heartbeat": now, + "registered_at": now, + }, + { + "input_addr": "tcp://stage:10002", + "output_addr": "tcp://stage:10002-out", + "stage_id": 0, + "status": "up", + "queue_length": 1, + "last_heartbeat": now, + "registered_at": now, + }, + { + "input_addr": "tcp://stage:10003", + "output_addr": "tcp://stage:10003-out", + "stage_id": 1, + "status": "error", + "queue_length": 5, + "last_heartbeat": now, + "registered_at": now, + }, + ] + + payload = {"instances": instances_payload, "timestamp": now} + pub.send(json.dumps(payload).encode("utf-8")) + + assert _wait_for_condition(lambda: len(client.get_instance_list().instances) == 3) + + inst_list = client.get_instance_list() + assert isinstance(inst_list, InstanceList) + assert len(inst_list.instances) == 3 + + for src, inst in zip(instances_payload, inst_list.instances, strict=True): + assert inst.input_addr == src["input_addr"] + assert inst.output_addr == src["output_addr"] + assert inst.stage_id == src["stage_id"] + assert inst.status.value == src["status"] + + stage0 = client.get_instances_for_stage(0) + stage1 = client.get_instances_for_stage(1) + + assert all(inst.stage_id == 0 for inst in stage0.instances) + assert all(inst.stage_id == 1 for inst in stage1.instances) + + # Send an updated list with fewer instances and verify cache refresh. + updated_payload = { + "instances": instances_payload[:2], + "timestamp": now + 1.0, + } + pub.send(json.dumps(updated_payload).encode("utf-8")) + + assert _wait_for_condition(lambda: len(client.get_instance_list().instances) == 2) + updated_list = client.get_instance_list() + assert len(updated_list.instances) == 2 + + client.close() + pub.close(0) + ctx.term() + + +def test_hub_client_close_closes_sub_socket(): + """Verify OmniCoordClientForHub.close() marks client as closed; second close raises.""" + ctx, pub, endpoint = _bind_pub() + client = OmniCoordClientForHub(endpoint) + client.close() + + with pytest.raises(RuntimeError, match="already closed"): + client.close() + + pub.close(0) + ctx.term() diff --git a/tests/distributed/omni_coordinator/test_omni_coord_client_for_stage.py b/tests/distributed/omni_coordinator/test_omni_coord_client_for_stage.py new file mode 100644 index 000000000..b74a48f49 --- /dev/null +++ b/tests/distributed/omni_coordinator/test_omni_coord_client_for_stage.py @@ -0,0 +1,110 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import json + +import zmq + +from vllm_omni.distributed.omni_coordinator import ( + OmniCoordClientForStage, + StageStatus, +) + + +def _bind_router() -> tuple[zmq.Context, zmq.Socket, str]: + ctx = zmq.Context.instance() + router = ctx.socket(zmq.ROUTER) + router.bind("tcp://127.0.0.1:*") + endpoint = router.getsockopt(zmq.LAST_ENDPOINT).decode("ascii") + return ctx, router, endpoint + + +def _recv_event(router: zmq.Socket) -> dict: + frames = router.recv_multipart() + # ROUTER adds identity frame; the last frame is the payload. + payload = frames[-1] + return json.loads(payload.decode("utf-8")) + + +def test_stage_client_auto_register_on_init(): + """Verify OmniCoordClientForStage automatically sends initial registration/status-up event when created.""" + ctx, router, endpoint = _bind_router() + + input_addr = "tcp://stage:10001" + output_addr = "tcp://stage:10001-out" + stage_id = 0 + + client = OmniCoordClientForStage(endpoint, input_addr, output_addr, stage_id) + + event = _recv_event(router) + + assert event["event_type"] == "update" + assert event["status"] == StageStatus.UP.value + assert event["stage_id"] == stage_id + assert event["input_addr"] == input_addr + assert event["output_addr"] == output_addr + + client.close() + router.close(0) + ctx.term() + + +def test_stage_client_update_info_sends_correct_event(): + """Verify OmniCoordClientForStage.update_info() sends status/load update events with expected fields.""" + ctx, router, endpoint = _bind_router() + + input_addr = "tcp://stage:10002" + output_addr = "tcp://stage:10002-out" + stage_id = 1 + + client = OmniCoordClientForStage(endpoint, input_addr, output_addr, stage_id) + + # Discard initial registration event. + _recv_event(router) + + client.update_info(status=StageStatus.ERROR) + client.update_info(queue_length=10) + + first = _recv_event(router) + second = _recv_event(router) + + assert first["status"] == StageStatus.ERROR.value + assert first["stage_id"] == stage_id + assert first["input_addr"] == input_addr + assert first["output_addr"] == output_addr + + assert second["queue_length"] == 10 + assert second["stage_id"] == stage_id + assert second["input_addr"] == input_addr + assert second["output_addr"] == output_addr + + client.close() + router.close(0) + ctx.term() + + +def test_stage_client_close_sends_down_status(): + """Verify close() sends final status-down event before closing underlying socket.""" + ctx, router, endpoint = _bind_router() + + input_addr = "tcp://stage:10003" + output_addr = "tcp://stage:10003-out" + stage_id = 2 + + client = OmniCoordClientForStage(endpoint, input_addr, output_addr, stage_id) + + # Discard initial registration event. + _recv_event(router) + + client.close() + + event = _recv_event(router) + assert event["status"] == StageStatus.DOWN.value + assert event["stage_id"] == stage_id + assert event["input_addr"] == input_addr + assert event["output_addr"] == output_addr + + assert client._socket.closed # DEALER socket no longer usable after close + + router.close(0) + ctx.term() diff --git a/tests/distributed/omni_coordinator/test_omni_coordinator.py b/tests/distributed/omni_coordinator/test_omni_coordinator.py new file mode 100644 index 000000000..0c68e61bb --- /dev/null +++ b/tests/distributed/omni_coordinator/test_omni_coordinator.py @@ -0,0 +1,209 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import json +import time + +import zmq +from vllm.v1.utils import get_engine_client_zmq_addr + +from vllm_omni.distributed.omni_coordinator import ( + OmniCoordClientForStage, + OmniCoordinator, + StageStatus, +) + + +def _recv_instance_list(sub: zmq.Socket, timeout_ms: int = 2000) -> dict | None: + """Receive InstanceList JSON from SUB socket. Returns None on timeout.""" + sub.setsockopt(zmq.RCVTIMEO, timeout_ms) + try: + data = sub.recv() + return json.loads(data.decode("utf-8")) + except zmq.Again: + return None + + +def _wait_for_instance_list( + sub: zmq.Socket, + expected_count: int, + timeout: float = 3.0, +) -> dict | None: + """Wait until received InstanceList with expected_count active instances.""" + start = time.time() + while time.time() - start < timeout: + msg = _recv_instance_list(sub, timeout_ms=500) + if msg is not None and len(msg.get("instances", [])) == expected_count: + return msg + return None + + +def test_omni_coordinator_registration_broadcast(): + """Verify that after multiple OmniCoordClientForStage instances register, + OmniCoordinator publishes an InstanceList containing all registered instances. + """ + router_addr = get_engine_client_zmq_addr( + local_only=False, + host="127.0.0.1", + port=0, + ) + pub_addr = get_engine_client_zmq_addr( + local_only=False, + host="127.0.0.1", + port=0, + ) + coordinator = OmniCoordinator( + router_zmq_addr=router_addr, + pub_zmq_addr=pub_addr, + heartbeat_timeout=1000.0, + ) + + sub_ctx = zmq.Context.instance() + sub = sub_ctx.socket(zmq.SUB) + sub.connect(pub_addr) + sub.setsockopt(zmq.SUBSCRIBE, b"") + + # ZMQ PUB/SUB slow-joiner: allow SUB to connect before clients register. + time.sleep(0.3) + + # Create 3 stage clients; each auto-registers on init. + clients = [ + OmniCoordClientForStage(router_addr, "tcp://stage:10001", "tcp://stage:10001-out", 0), + OmniCoordClientForStage(router_addr, "tcp://stage:10002", "tcp://stage:10002-out", 0), + OmniCoordClientForStage(router_addr, "tcp://stage:10003", "tcp://stage:10003-out", 1), + ] + + msg = _wait_for_instance_list(sub, expected_count=3) + assert msg is not None, "Expected InstanceList with 3 instances" + assert len(msg["instances"]) == 3 + assert isinstance(msg["timestamp"], (int, float)) + + input_addrs = {inst["input_addr"] for inst in msg["instances"]} + assert "tcp://stage:10001" in input_addrs + assert "tcp://stage:10002" in input_addrs + assert "tcp://stage:10003" in input_addrs + + for c in clients: + c.close() + coordinator.close() + sub.close(0) + sub_ctx.term() + + +def test_omni_coordinator_heartbeat_timeout_handling(): + """Verify that when a stage instance stops sending heartbeats, + OmniCoordinator marks it as unhealthy and excludes it from the active list. + """ + router_addr = get_engine_client_zmq_addr( + local_only=False, + host="127.0.0.1", + port=0, + ) + pub_addr = get_engine_client_zmq_addr( + local_only=False, + host="127.0.0.1", + port=0, + ) + coordinator = OmniCoordinator( + router_zmq_addr=router_addr, + pub_zmq_addr=pub_addr, + heartbeat_timeout=5.0, + ) + + sub_ctx = zmq.Context.instance() + sub = sub_ctx.socket(zmq.SUB) + sub.connect(pub_addr) + sub.setsockopt(zmq.SUBSCRIBE, b"") + + time.sleep(0.3) + + # A and B: real clients that send heartbeats every 5s. + client_a = OmniCoordClientForStage(router_addr, "tcp://stage:a", "tcp://stage:a-out", 0) + client_b = OmniCoordClientForStage(router_addr, "tcp://stage:b", "tcp://stage:b-out", 0) + + # C: raw DEALER that sends only registration, no heartbeat. + dealer_ctx = zmq.Context.instance() + dealer_c = dealer_ctx.socket(zmq.DEALER) + dealer_c.connect(router_addr) + reg_event = { + "input_addr": "tcp://stage:c", + "output_addr": "tcp://stage:c-out", + "stage_id": 0, + "event_type": "update", + "status": StageStatus.UP.value, + "queue_length": 0, + } + dealer_c.send(json.dumps(reg_event).encode("utf-8")) + + msg = _wait_for_instance_list(sub, expected_count=3) + assert msg is not None, "Expected initial 3 instances" + assert len(msg["instances"]) == 3 + + # Wait for heartbeat timeout (timeout=5s, check interval ~2.5s). + time.sleep(8.0) + + # Receive the update (C should be ERROR and excluded from active list). + msg_after_timeout = _wait_for_instance_list(sub, expected_count=2, timeout=5.0) + assert msg_after_timeout is not None, "Expected InstanceList with 2 instances after timeout" + instances = msg_after_timeout.get("instances", []) + input_addrs = {inst["input_addr"] for inst in instances} + + assert "tcp://stage:a" in input_addrs + assert "tcp://stage:b" in input_addrs + assert "tcp://stage:c" not in input_addrs + + client_a.close() + client_b.close() + dealer_c.close(0) + coordinator.close() + sub.close(0) + dealer_ctx.term() + sub_ctx.term() + + +def test_omni_coordinator_instance_shutdown_handling(): + """Verify that when a stage instance sends status='down', + OmniCoordinator removes it from the active list and broadcasts an updated list. + """ + router_addr = get_engine_client_zmq_addr( + local_only=False, + host="127.0.0.1", + port=0, + ) + pub_addr = get_engine_client_zmq_addr( + local_only=False, + host="127.0.0.1", + port=0, + ) + coordinator = OmniCoordinator( + router_zmq_addr=router_addr, + pub_zmq_addr=pub_addr, + heartbeat_timeout=1000.0, + ) + + sub_ctx = zmq.Context.instance() + sub = sub_ctx.socket(zmq.SUB) + sub.connect(pub_addr) + sub.setsockopt(zmq.SUBSCRIBE, b"") + + time.sleep(0.3) # PUB/SUB slow-joiner + + client = OmniCoordClientForStage(router_addr, "tcp://stage:shutdown", "tcp://stage:shutdown-out", 0) + + msg = _wait_for_instance_list(sub, expected_count=1) + assert msg is not None + assert len(msg["instances"]) == 1 + assert msg["instances"][0]["input_addr"] == "tcp://stage:shutdown" + + # Send down status (simulating graceful shutdown). + client.update_info(status=StageStatus.DOWN) + + # Receive updated list (should have 0 active instances). + msg = _wait_for_instance_list(sub, expected_count=0) + assert msg is not None + assert len(msg["instances"]) == 0 + + client.close() + coordinator.close() + sub.close(0) + sub_ctx.term() diff --git a/vllm_omni/distributed/omni_coordinator/__init__.py b/vllm_omni/distributed/omni_coordinator/__init__.py new file mode 100644 index 000000000..cbef920d4 --- /dev/null +++ b/vllm_omni/distributed/omni_coordinator/__init__.py @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from .load_balancer import ( + LoadBalancer, + LoadBalancingPolicy, + RandomBalancer, + Task, +) +from .messages import InstanceEvent, InstanceInfo, InstanceList, StageStatus +from .omni_coord_client_for_hub import OmniCoordClientForHub +from .omni_coord_client_for_stage import OmniCoordClientForStage +from .omni_coordinator import OmniCoordinator + +__all__ = [ + "OmniCoordinator", + "StageStatus", + "InstanceEvent", + "InstanceInfo", + "InstanceList", + "OmniCoordClientForStage", + "OmniCoordClientForHub", + "Task", + "LoadBalancer", + "LoadBalancingPolicy", + "RandomBalancer", +] diff --git a/vllm_omni/distributed/omni_coordinator/load_balancer.py b/vllm_omni/distributed/omni_coordinator/load_balancer.py new file mode 100644 index 000000000..15a079b0a --- /dev/null +++ b/vllm_omni/distributed/omni_coordinator/load_balancer.py @@ -0,0 +1,82 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from __future__ import annotations + +import random +from abc import ABC, abstractmethod +from enum import Enum +from typing import Any, TypedDict + +from .messages import InstanceInfo + + +class Task(TypedDict, total=False): + """Task structure passed from async_omni (stage.submit(task)). + + Mirrors the dict built in AsyncOmni with request_id, engine_inputs, + sampling_params. Future load-balancing policies may use these fields. + """ + + request_id: str + engine_inputs: Any + sampling_params: Any + + +class LoadBalancingPolicy(str, Enum): + """Enumeration for load balancing policies. + + Only ``RANDOM`` is implemented. Additional policies (e.g. round-robin, + least-connections) can be added in the future. + """ + + RANDOM = "random" + + +class LoadBalancer(ABC): + """Abstract base class for load balancers. + + Subclasses implement :meth:`select` to choose an instance for a given task. + """ + + @abstractmethod + def select(self, task: Task, instances: list[InstanceInfo]) -> int: + """Route a task to one of the available instances. + + Args: + task: The task to route. Not used by the random policy but reserved + for future strategies that may inspect task metadata. + instances: List of available instances to choose from. + + Returns: + Index of the selected instance in ``instances``. + + Raises: + ValueError: If ``instances`` is empty. + """ + + raise NotImplementedError + + +class RandomBalancer(LoadBalancer): + """Load balancer that selects an instance uniformly at random. + + This is the initial and only policy supported. It intentionally ignores + the task payload and chooses a random index from the provided instance + list. More sophisticated policies (e.g. round-robin, least-connections) + can be implemented as additional subclasses of :class:`LoadBalancer`. + """ + + def select(self, task: Task, instances: list[InstanceInfo]) -> int: # noqa: ARG002 + if not instances: + raise ValueError("instances must not be empty") + + return random.randrange(len(instances)) + + +__all__ = [ + "Task", + "LoadBalancingPolicy", + "LoadBalancer", + "RandomBalancer", +] diff --git a/vllm_omni/distributed/omni_coordinator/messages.py b/vllm_omni/distributed/omni_coordinator/messages.py new file mode 100644 index 000000000..2bb590139 --- /dev/null +++ b/vllm_omni/distributed/omni_coordinator/messages.py @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +class StageStatus(str, Enum): + """Enumeration for stage instance status.""" + + UP = "up" # Instance is ready and available + DOWN = "down" # Instance is shutdown gracefully + ERROR = "error" # Instance encountered an error or timeout + + +@dataclass +class InstanceEvent: + """Wire payload from OmniCoordClientForStage to OmniCoordinator. + + Schema for Stage → Coordinator events over ZMQ: + input_addr, output_addr, stage_id, status, queue_length, event_type. + """ + + input_addr: str # Stage instance input ZMQ address (e.g., "tcp://host:port") + output_addr: str # Stage instance output ZMQ address (e.g., "tcp://host:port") + stage_id: int # Stage ID + event_type: str # "update" | "heartbeat" + status: StageStatus # Current status + queue_length: int # Current queue length + + +@dataclass +class InstanceInfo: + """Metadata for a single stage instance. + + This type is stored in OmniCoordinator's internal registry and is also + published to hubs via :class:`InstanceList`. + """ + + input_addr: str # Stage instance input ZMQ address (e.g., "tcp://host:port") + output_addr: str # Stage instance output ZMQ address (e.g., "tcp://host:port") + stage_id: int # Stage ID of this instance + status: StageStatus # Current status of the instance + queue_length: int # Current queue length of this instance + last_heartbeat: float # Timestamp of the last heartbeat received (seconds) + registered_at: float # Timestamp when the instance was registered (seconds) + + +@dataclass +class InstanceList: + """Container for instance list updates. + + OmniCoordinator publishes an :class:`InstanceList` whenever its view of + active instances changes. OmniCoordClientForHub caches the latest value + and exposes it to AsyncOmni and the load balancer. + """ + + instances: list[InstanceInfo] + timestamp: float # Time when the list was last updated (seconds) diff --git a/vllm_omni/distributed/omni_coordinator/omni_coord_client_for_hub.py b/vllm_omni/distributed/omni_coordinator/omni_coord_client_for_hub.py new file mode 100644 index 000000000..9081e4591 --- /dev/null +++ b/vllm_omni/distributed/omni_coordinator/omni_coord_client_for_hub.py @@ -0,0 +1,164 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import json +import logging +import threading +from time import sleep, time +from typing import Any + +import zmq + +from .messages import InstanceInfo, InstanceList, StageStatus + +logger = logging.getLogger(__name__) + + +class OmniCoordClientForHub: + """Client for AsyncOmni side to receive instance list updates. + + This client maintains a SUB socket connected to OmniCoordinator's PUB + endpoint and caches the latest :class:`InstanceList` in memory for use by + the load balancer and routing logic. + """ + + def __init__(self, coord_zmq_addr: str) -> None: + """Initialize client and start receive thread (socket created in thread).""" + self._coord_zmq_addr = coord_zmq_addr + + self._ctx = zmq.Context() + self._lock = threading.Lock() + self._instance_list: InstanceList | None = None + self._closed = False + self._stop_event = threading.Event() + self._init_done = threading.Event() + self._init_error: list[BaseException] = [] + + self._thread = threading.Thread(target=self._recv_loop, daemon=True) + self._thread.start() + + self._init_done.wait(timeout=5.0) + if self._init_error: + raise RuntimeError(f"Failed to connect to coordinator at {self._coord_zmq_addr}") from self._init_error[0] + + def _decode_instance_list(self, payload: dict[str, Any]) -> InstanceList: + """Convert a JSON-decoded dict into an :class:`InstanceList`.""" + instances_payload = payload.get("instances", []) + instances: list[InstanceInfo] = [] + for inst in instances_payload: + instances.append( + InstanceInfo( + input_addr=inst["input_addr"], + output_addr=inst["output_addr"], + stage_id=int(inst["stage_id"]), + status=StageStatus(inst["status"]), + queue_length=int(inst["queue_length"]), + last_heartbeat=float(inst["last_heartbeat"]), + registered_at=float(inst["registered_at"]), + ) + ) + + timestamp = float(payload.get("timestamp", time())) + return InstanceList(instances=instances, timestamp=timestamp) + + def _recv_loop(self) -> None: + """Background loop that receives and caches instance lists.""" + sub = None + try: + sub = self._ctx.socket(zmq.SUB) + sub.setsockopt(zmq.SUBSCRIBE, b"") + sub.setsockopt(zmq.RCVTIMEO, 100) # 100ms timeout, avoids busy-wait + sub.connect(self._coord_zmq_addr) + except (zmq.ZMQError, OSError) as e: + self._init_error.append(e) + sub = None + finally: + self._init_done.set() + + try: + while not self._stop_event.is_set(): + # (Re)create and connect SUB socket if needed. + if sub is None: + try: + sub = self._ctx.socket(zmq.SUB) + sub.setsockopt(zmq.SUBSCRIBE, b"") + sub.setsockopt(zmq.RCVTIMEO, 100) # 100ms timeout, avoids busy-wait + sub.connect(self._coord_zmq_addr) + except (zmq.ZMQError, OSError) as e: + logger.error( + "Hub client failed to connect to coordinator at %s, will retry", + self._coord_zmq_addr, + exc_info=e, + ) + if sub is not None: + try: + sub.close() + except zmq.ZMQError: + pass + sub = None + sleep(1.0) + continue + + try: + data = sub.recv() + except zmq.Again: + continue + except zmq.ZMQError as e: + logger.error("Hub client recv failed, will reconnect", exc_info=e) + try: + sub.close() + except zmq.ZMQError: + pass + sub = None + sleep(1.0) + continue + + try: + payload = json.loads(data.decode("utf-8")) + inst_list = self._decode_instance_list(payload) + with self._lock: + self._instance_list = inst_list + except ( + json.JSONDecodeError, + KeyError, + ValueError, + TypeError, + AttributeError, + ) as e: + logger.warning("Invalid instance list message, skipping: %s", e) + finally: + try: + if sub is not None: + sub.close() + except zmq.ZMQError: + pass + try: + self._ctx.term() + except zmq.ZMQError: + pass + + def get_instance_list(self) -> InstanceList: + """Return the latest cached :class:`InstanceList`. + + If no update has been received yet, returns an empty list with + ``timestamp=0.0``. + """ + with self._lock: + if self._instance_list is None: + return InstanceList(instances=[], timestamp=0.0) + return self._instance_list + + def get_instances_for_stage(self, stage_id: int) -> InstanceList: + """Return instances filtered by ``stage_id``.""" + base = self.get_instance_list() + filtered = [inst for inst in base.instances if inst.stage_id == stage_id] + return InstanceList(instances=filtered, timestamp=base.timestamp) + + def close(self) -> None: + """Close the SUB socket and stop the background thread.""" + if self._closed: + raise RuntimeError("Client already closed") + + self._closed = True + self._stop_event.set() + self._thread.join(timeout=1.0) diff --git a/vllm_omni/distributed/omni_coordinator/omni_coord_client_for_stage.py b/vllm_omni/distributed/omni_coordinator/omni_coord_client_for_stage.py new file mode 100644 index 000000000..cd5c357bb --- /dev/null +++ b/vllm_omni/distributed/omni_coordinator/omni_coord_client_for_stage.py @@ -0,0 +1,193 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import json +import logging +import threading +import time +from dataclasses import asdict + +import zmq + +from .messages import InstanceEvent, StageStatus + +logger = logging.getLogger(__name__) + + +class OmniCoordClientForStage: + """Client used by stage instances to send events to OmniCoordinator. + + This client maintains a DEALER socket connected to OmniCoordinator's + ROUTER endpoint and sends JSON-encoded events describing instance status. + """ + + def __init__( + self, + coord_zmq_addr: str, + input_addr: str, + output_addr: str, + stage_id: int, + ) -> None: + """Initialize client and send initial registration / status-up event.""" + self._coord_zmq_addr = coord_zmq_addr + self._input_addr = input_addr + self._output_addr = output_addr + self._stage_id = stage_id + + self._ctx = zmq.Context() + self._socket = self._ctx.socket(zmq.DEALER) + try: + self._socket.connect(self._coord_zmq_addr) + except zmq.ZMQError as e: + self._socket.close() + raise RuntimeError(f"Failed to connect to coordinator at {self._coord_zmq_addr}: {e}") from e + + self._status = StageStatus.UP + self._queue_length = 0 + self._closed = False + self._heartbeat_interval = 5.0 + self._stop_event = threading.Event() + self._send_lock = threading.Lock() + + self._send_event("update") + + self._heartbeat_thread = threading.Thread( + target=self._heartbeat_loop, + daemon=True, + ) + self._heartbeat_thread.start() + + def _reconnect(self) -> bool: + """Best-effort reconnect with up to ``max_retries`` attempts. + + Each attempt closes the current socket/context, sleeps 5 seconds, + then creates a new DEALER socket and reconnects to the coordinator. + Caller must hold ``_send_lock``. + Returns True on success, False if all attempts fail. + """ + while not self._stop_event.is_set() and not self._closed: + try: + self._socket.close(0) + except zmq.ZMQError: + pass + try: + self._ctx.term() + except zmq.ZMQError: + pass + + time.sleep(5.0) + + try: + self._ctx = zmq.Context() + self._socket = self._ctx.socket(zmq.DEALER) + self._socket.connect(self._coord_zmq_addr) + return True + except zmq.ZMQError as e: + logger.error( + "Stage client reconnect failed, will retry in 5s (coord=%s)", + self._coord_zmq_addr, + exc_info=e, + ) + continue + return False + + def _send_event(self, event_type: str) -> None: + """Send an InstanceEvent to OmniCoordinator. + + Wire format: input_addr, output_addr, stage_id, status, queue_length, event_type. + For "update": includes status and queue_length from instance state. + For "heartbeat": status and queue_length are null. + + On send failure (ZMQError / RuntimeError), attempts to reconnect up + to 3 times (5s sleep each) and retries the send once after a + successful reconnect. Raises if reconnect or the retry send fails. + """ + if self._closed: + raise RuntimeError("Client already closed") + + event = InstanceEvent( + input_addr=self._input_addr, + output_addr=self._output_addr, + stage_id=self._stage_id, + event_type=event_type, + status=self._status, + queue_length=self._queue_length, + ) + data = json.dumps(asdict(event)).encode("utf-8") + + with self._send_lock: + try: + self._socket.send(data, flags=zmq.NOBLOCK) + return + except zmq.Again: + logger.debug("Send buffer full, dropping event") + return + except (RuntimeError, zmq.ZMQError) as e: + # First send failed; try reconnecting a few times. + if not self._reconnect: + logger.error("Failed to send event and reconnect to coordinator", exc_info=e) + raise + + # Reconnected successfully; try sending once more. + try: + self._socket.send(data, flags=zmq.NOBLOCK) + except zmq.Again: + logger.debug("Send buffer full after reconnect, dropping event") + except (RuntimeError, zmq.ZMQError) as e2: + logger.error("Failed to send event after successful reconnect", exc_info=e2) + raise + + def update_info( + self, + status: StageStatus | None = None, + queue_length: int | None = None, + ) -> None: + """Update instance information and notify OmniCoordinator. + + At least one of ``status`` or ``queue_length`` must be provided. + """ + if status is None and queue_length is None: + raise ValueError("At least one of status or queue_length must be provided") + + if status is not None: + self._status = status + if queue_length is not None: + self._queue_length = queue_length + + self._send_event("update") + + def _heartbeat_loop(self) -> None: + """Periodically send heartbeat events while the client is alive.""" + while not self._stop_event.wait(timeout=self._heartbeat_interval): + if self._closed: + break + + try: + self._send_event("heartbeat") + except (RuntimeError, zmq.ZMQError): + break + + def close(self) -> None: + """Send a final down event and close the underlying socket.""" + if self._closed: + raise RuntimeError("Client already closed") + + # Stop heartbeat thread first to avoid concurrent sends during shutdown. + self._stop_event.set() + if hasattr(self, "_heartbeat_thread"): + self._heartbeat_thread.join(timeout=1.0) + + # Mark status as DOWN and send one last update. + self._status = StageStatus.DOWN + try: + self._send_event("update") + except zmq.ZMQError: + pass # Socket may already be broken, proceed with close + + # Close DEALER socket and terminate this client's context. + self._socket.close(0) + try: + self._ctx.term() + except zmq.ZMQError: + pass + self._closed = True diff --git a/vllm_omni/distributed/omni_coordinator/omni_coordinator.py b/vllm_omni/distributed/omni_coordinator/omni_coordinator.py new file mode 100644 index 000000000..7ff608f2f --- /dev/null +++ b/vllm_omni/distributed/omni_coordinator/omni_coordinator.py @@ -0,0 +1,330 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + + +import json +import logging +import threading +from dataclasses import asdict +from time import time +from typing import Any + +import zmq + +from .messages import InstanceEvent, InstanceInfo, InstanceList, StageStatus + +logger = logging.getLogger(__name__) + + +class OmniCoordinator: + """Coordinator for stage instances and hub clients. + + This service receives instance events from :class:`OmniCoordClientForStage` + via a ZMQ ROUTER socket and publishes active instance lists to + :class:`OmniCoordClientForHub` via a PUB socket. + + The coordinator maintains an in-memory registry of all known instances, + including their status, queue length, and heartbeat timestamps. A + background thread periodically checks for heartbeat timeouts and marks + unhealthy instances as ``StageStatus.ERROR``. + """ + + def __init__( + self, + router_zmq_addr: str, + pub_zmq_addr: str, + heartbeat_timeout: float = 30.0, + ) -> None: + """Initialize coordinator and start background service loops. + + Args: + router_zmq_addr: ZMQ address to bind the ROUTER socket. + pub_zmq_addr: ZMQ address to bind the PUB socket. + heartbeat_timeout: Seconds before an instance is considered + unhealthy if no heartbeat / update is received. + """ + self._router_zmq_addr = router_zmq_addr + self._pub_zmq_addr = pub_zmq_addr + self._heartbeat_timeout = heartbeat_timeout + + # Dedicated ZMQ context for this coordinator instance. + self._ctx = zmq.Context() + self._router = self._ctx.socket(zmq.ROUTER) + self._router.bind(self._router_zmq_addr) + + self._pub = self._ctx.socket(zmq.PUB) + self._pub.bind(self._pub_zmq_addr) + + self._instances: dict[str, InstanceInfo] = {} + self._lock = threading.Lock() + self._pub_lock = threading.Lock() + + self._publish_min_interval: float = 0.1 # seconds + self._pending_broadcast: bool = False + + self._running = True + self._closed = False + self._stop_event = threading.Event() + + self._router.setsockopt(zmq.RCVTIMEO, 100) + + self._recv_thread = threading.Thread(target=self._recv_loop, daemon=True) + self._recv_thread.start() + + self._periodic_thread = threading.Thread(target=self._periodic_loop, daemon=True) + self._periodic_thread.start() + + def get_active_instances(self) -> InstanceList: + """Return an :class:`InstanceList` of active (UP) instances only.""" + with self._lock: + active = [inst for inst in self._instances.values() if inst.status == StageStatus.UP] + return InstanceList(instances=active, timestamp=time()) + + def add_new_instance(self, event: InstanceEvent) -> None: + """Add a new instance based on an incoming event.""" + with self._lock: + self._add_new_instance_locked(event) + self.publish_instance_list_update() + + def update_instance_info(self, event: InstanceEvent) -> None: + """Update an existing instance based on an incoming event.""" + with self._lock: + self._update_instance_info_locked(event) + self.publish_instance_list_update() + + def remove_instance(self, event: InstanceEvent) -> None: + """Mark an instance as removed / down based on an incoming event. + + This marks the instance's status as DOWN or ERROR (depending on the + event) but keeps it in the internal registry. It is removed from the + *active* instance list published to hubs. + """ + with self._lock: + self._remove_instance_locked(event) + self.publish_instance_list_update() + + def publish_instance_list_update(self) -> None: + """Publish the current active instance list to all subscribers.""" + active_list = self.get_active_instances() + payload = asdict(active_list) + data = json.dumps(payload).encode("utf-8") + + with self._pub_lock: + try: + # PUB socket is best-effort; drop update if not ready. + self._pub.send(data, flags=zmq.NOBLOCK) + except (zmq.Again, zmq.ZMQError): + # Silently ignore send failures; next update will catch up. + return + + def _schedule_broadcast(self, force: bool) -> None: + """Schedule a broadcast, optionally bypassing throttling. + + When ``force`` is True, publish immediately. Otherwise, mark a pending + broadcast that will be flushed by the periodic loop at most once per + ``_publish_min_interval``. + """ + if force: + self.publish_instance_list_update() + else: + self._pending_broadcast = True + + def _mark_instance_error_locked(self, info: InstanceInfo) -> None: + """Mark instance as ERROR (e.g. after heartbeat timeout).""" + info.status = StageStatus.ERROR + + def _check_heartbeat_timeouts(self) -> None: + """Mark instances as ERROR if their heartbeat has timed out.""" + now = time() + timed_out = False + gc_ttl = 600.0 # 10 minutes + + with self._lock: + to_delete: list[str] = [] + + for input_addr, info in self._instances.items(): + if info.status == StageStatus.UP and now - info.last_heartbeat > self._heartbeat_timeout: + self._mark_instance_error_locked(info) + timed_out = True + elif info.status in (StageStatus.DOWN, StageStatus.ERROR) and now - info.last_heartbeat > gc_ttl: + to_delete.append(input_addr) + + for input_addr in to_delete: + del self._instances[input_addr] + if timed_out: + # Instance liveness changed; force immediate broadcast. + self._schedule_broadcast(force=True) + + def close(self) -> None: + """Shut down background threads and close all ZMQ sockets.""" + if self._closed: + raise RuntimeError("Coordinator already closed") + + self._closed = True + self._running = False + self._stop_event.set() + + # Wait for threads to exit before closing sockets. + for thread in (self._recv_thread, self._periodic_thread): + thread.join(timeout=1.0) + + try: + self._router.close(0) + except zmq.ZMQError: + pass + + try: + self._pub.close(0) + except zmq.ZMQError: + pass + + try: + self._ctx.term() + except zmq.ZMQError: + pass + + def _parse_instance_event(self, data: dict[str, Any]) -> InstanceEvent | None: + """Parse wire payload dict into InstanceEvent. Returns None if invalid.""" + try: + return InstanceEvent( + input_addr=str(data["input_addr"]), + output_addr=str(data["output_addr"]), + stage_id=int(data["stage_id"]), + event_type=str(data["event_type"]), + status=StageStatus(data.get("status")), + queue_length=data.get("queue_length"), + ) + except (KeyError, ValueError, TypeError): + return None + + def _recv_loop(self) -> None: + """Background loop that receives and processes instance events.""" + while self._running: + try: + frames = self._router.recv_multipart() + except zmq.Again: + # RCVTIMEO expired, loop to recheck _running. + continue + except zmq.ZMQError: + # Socket likely closed or context terminated. + break + + if not frames: + continue + + payload = frames[-1] + try: + data = json.loads(payload.decode("utf-8")) + event = self._parse_instance_event(data) + except json.JSONDecodeError as e: + logger.warning("Invalid JSON in instance event, dropping: %s", e) + continue + if event is None: + logger.warning("Malformed instance event, dropping") + continue + + self._handle_event(event) + + def _periodic_loop(self) -> None: + """Periodic loop to check heartbeat timeouts and flush broadcasts. + + Heartbeat timeouts are checked on their original cadence, while + queue_length / non-liveness updates are coalesced and flushed at + most once per ``_publish_min_interval``. + """ + heartbeat_interval = max(1.0, min(self._heartbeat_timeout / 2.0, 5.0)) + loop_interval = self._publish_min_interval + + last_heartbeat_check = 0.0 + while self._running: + now = time() + + if now - last_heartbeat_check >= heartbeat_interval: + self._check_heartbeat_timeouts() + last_heartbeat_check = now + + if self._pending_broadcast: + self.publish_instance_list_update() + self._pending_broadcast = False + + if self._stop_event.wait(timeout=loop_interval): + break + + def _handle_event(self, event: InstanceEvent) -> None: + """Dispatch an incoming event to the appropriate handler.""" + try: + input_addr = event.input_addr + + # Heartbeat: only update last_heartbeat; if previously ERROR, + # promote back to UP and broadcast once. + if event.event_type == "heartbeat": + promote = False + with self._lock: + info = self._instances.get(input_addr) + if info is not None: + info.last_heartbeat = time() + if info.status == StageStatus.ERROR: + info.status = StageStatus.UP + promote = True + if promote: + self._schedule_broadcast(force=True) + return + + # Check-and-act under single lock to avoid TOCTOU race (duplicate + # registration when concurrent events arrive for the same instance). + with self._lock: + force_broadcast = False + if input_addr not in self._instances: + self._add_new_instance_locked(event) + force_broadcast = True + else: + if event.status == StageStatus.DOWN: + self._remove_instance_locked(event) + force_broadcast = True + else: + self._update_instance_info_locked(event) + + # New instances / DOWN events are broadcast immediately; other + # updates (e.g. queue_length changes) are throttled via the + # periodic loop. + self._schedule_broadcast(force=force_broadcast) + except (KeyError, ValueError, TypeError) as e: + logger.warning("Dropping malformed event: %s", e) + + def _add_new_instance_locked(self, event: InstanceEvent) -> None: + input_addr = event.input_addr + if not input_addr: + raise KeyError("input_addr required") + stage_id = event.stage_id + if stage_id < 0: + raise KeyError("stage_id required and must be non-negative") + + now = time() + info = InstanceInfo( + input_addr=input_addr, + output_addr=event.output_addr, + stage_id=stage_id, + status=event.status, + queue_length=event.queue_length, + last_heartbeat=now, + registered_at=now, + ) + self._instances[input_addr] = info + + def _update_instance_info_locked(self, event: InstanceEvent) -> None: + input_addr = event.input_addr + info = self._instances[input_addr] + + if event.status is not None: + info.status = event.status + + if event.queue_length is not None: + info.queue_length = event.queue_length + + def _remove_instance_locked(self, event: InstanceEvent) -> None: + input_addr = event.input_addr + info = self._instances.get(input_addr) + if info is None: + return + + info.status = StageStatus.DOWN From 4d89ebaa07b7726d203b63c78232567011169334 Mon Sep 17 00:00:00 2001 From: JohnJan Date: Wed, 11 Mar 2026 14:44:18 +0800 Subject: [PATCH 04/38] [Model]: add FLUX.2-dev model (#1629) Co-authored-by: wuzhongjian --- docs/models/supported_models.md | 1 + .../diffusion/parallelism_acceleration.md | 1 + docs/user_guide/diffusion_acceleration.md | 1 + .../offline_inference/text_to_image/README.md | 17 + .../models/flux2/test_flux2_transformer_tp.py | 244 ++++ vllm_omni/diffusion/models/flux2/__init__.py | 17 + .../models/flux2/flux2_transformer.py | 770 ++++++++++++ .../diffusion/models/flux2/pipeline_flux2.py | 1088 +++++++++++++++++ vllm_omni/diffusion/registry.py | 6 + 9 files changed, 2145 insertions(+) create mode 100644 tests/diffusion/models/flux2/test_flux2_transformer_tp.py create mode 100644 vllm_omni/diffusion/models/flux2/__init__.py create mode 100644 vllm_omni/diffusion/models/flux2/flux2_transformer.py create mode 100644 vllm_omni/diffusion/models/flux2/pipeline_flux2.py diff --git a/docs/models/supported_models.md b/docs/models/supported_models.md index 56b5a0f6b..67a49c575 100644 --- a/docs/models/supported_models.md +++ b/docs/models/supported_models.md @@ -49,6 +49,7 @@ th { |`GlmImageForConditionalGeneration` | GLM-Image | `zai-org/GLM-Image` | |`NextStep11Pipeline` | NextStep-1.1 | `stepfun-ai/NextStep-1.1` | |`MiMoAudioForConditionalGeneration` | MiMo-Audio-7B-Instruct | `XiaomiMiMo/MiMo-Audio-7B-Instruct` | +|`Flux2Pipeline` | FLUX.2-dev | `black-forest-labs/FLUX.2-dev` | ## List of Supported Models for NPU diff --git a/docs/user_guide/diffusion/parallelism_acceleration.md b/docs/user_guide/diffusion/parallelism_acceleration.md index bd2a58799..505f9627f 100644 --- a/docs/user_guide/diffusion/parallelism_acceleration.md +++ b/docs/user_guide/diffusion/parallelism_acceleration.md @@ -37,6 +37,7 @@ The following table shows which models are currently supported by parallelism me | **Stable-Diffusion3.5** | `stabilityai/stable-diffusion-3.5` | ❌ | ❌ | ❌ | ✅ | ✅ | N/A | | **FLUX.2-klein** | `black-forest-labs/FLUX.2-klein-4B` | ❌ | ❌ | ❌ | ✅ | ❌ | N/A | | **FLUX.1-dev** | `black-forest-labs/FLUX.1-dev` | ❌ | ❌ | ✅ | ✅ | ❌ | N/A | +| **FLUX.2-dev** | `black-forest-labs/FLUX.2-dev` | ❌ | ❌ | ❌ | ✅ | ❌ | N/A | | **HunyuanImage3.0** | `tencent/HunyuanImage-3.0`, `tencent/HunyuanImage-3.0-Instruct` | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | !!! note "TP Limitations for Diffusion Models" diff --git a/docs/user_guide/diffusion_acceleration.md b/docs/user_guide/diffusion_acceleration.md index 462ab8b18..ef4b39d0a 100644 --- a/docs/user_guide/diffusion_acceleration.md +++ b/docs/user_guide/diffusion_acceleration.md @@ -69,6 +69,7 @@ The following table shows which models are currently supported by each accelerat | **FLUX.1-dev** | `black-forest-labs/FLUX.1-dev` | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | | **NextStep-1.1** | `stepfun-ai/NextStep-1.1` | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | | **FLUX.2-klein** | `black-forest-labs/FLUX.2-klein-4B` | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | +| **FLUX.2-dev** | `black-forest-labs/FLUX.2-dev` | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ### VideoGen diff --git a/examples/offline_inference/text_to_image/README.md b/examples/offline_inference/text_to_image/README.md index ff2dda010..2513627e8 100644 --- a/examples/offline_inference/text_to_image/README.md +++ b/examples/offline_inference/text_to_image/README.md @@ -106,6 +106,23 @@ python text_to_image.py \ --seed 42 ``` +### Flux.2-dev Models +To start Flux.2-dev with a single GPU, cpu-offload must be enabled because the total size of its weights exceeds the 80GB memory capacity of the GPU. +```bash +python examples/offline_inference/text_to_image/text_to_image.py \ + --model black-forest-labs/FLUX.2-dev \ + --prompt "a lovely bunny holding a sign that says 'vllm-omni'" \ + --seed 42 \ + --tensor-parallel-size 1 \ + --num-images-per-prompt 1 \ + --num-inference-steps 50 \ + --guidance-scale 4.0 \ + --height 1024 \ + --width 1024 \ + --enable-cpu-offload \ + --output flux2-dev.png +``` + ### Key Arguments **Common arguments:** diff --git a/tests/diffusion/models/flux2/test_flux2_transformer_tp.py b/tests/diffusion/models/flux2/test_flux2_transformer_tp.py new file mode 100644 index 000000000..a2d1fe6ab --- /dev/null +++ b/tests/diffusion/models/flux2/test_flux2_transformer_tp.py @@ -0,0 +1,244 @@ +from unittest.mock import MagicMock, patch + +import pytest +import torch + +from vllm_omni.diffusion.models.flux2.flux2_transformer import ( + Flux2PosEmbed, + Flux2Transformer2DModel, +) + + +# Initialize TP group before tests +@pytest.fixture(scope="function", autouse=True) +def setup_tp_group(): + """Set up TP group for each test function""" + with patch("vllm.model_executor.layers.linear.get_tensor_model_parallel_world_size", return_value=2): + with patch("vllm.distributed.parallel_state.get_tp_group") as mock_get_tp_group: + mock_tp_group = MagicMock() + mock_tp_group.world_size = 2 + mock_get_tp_group.return_value = mock_tp_group + yield + + +class TestFlux2TransformerWeightLoading: + """Test Flux2Transformer weight loading functionality""" + + def test_weight_loading_tp2(self, setup_tp_group): + """Verify weights load correctly with TP=2""" + # Prepare test data + model = Flux2Transformer2DModel( + num_layers=2, + num_single_layers=1, + num_attention_heads=48, + attention_head_dim=128, + joint_attention_dim=15360, + ) + + # Mock TP=2 weight loading + mock_weights = [] + + # 1. Test regular weight loading + mock_weights.append(("x_embedder.weight", torch.randn(6144, 128))) + mock_weights.append(("context_embedder.weight", torch.randn(6144, 15360))) + mock_weights.append(("proj_out.weight", torch.randn(128, 6144))) + + # 2. Test stacked_params_mapping weight loading + # Full weights - load_weights handles sharding internally + to_qkv_weight = torch.randn(18432, 6144) # Full weights + mock_weights.append(("transformer_blocks.0.attn.to_qkv.weight", to_qkv_weight)) + + # Add_kv_proj weights + add_kv_proj_weight = torch.randn(18432, 6144) # Full weights + mock_weights.append(("transformer_blocks.0.attn.add_kv_proj.weight", add_kv_proj_weight)) + + # 3. Test single block weight loading + single_block_qkv_mlp_weight = torch.randn(18432 + 18432 * 3, 6144) # Full weights + mock_weights.append(("single_transformer_blocks.0.attn.to_qkv_mlp_proj.weight", single_block_qkv_mlp_weight)) + + # Execute weight loading + loaded_params = model.load_weights(mock_weights) + + # Verify + assert len(loaded_params) > 0, "Parameters should be loaded" + + # Verify stacked_params_mapping is correctly set + assert model.stacked_params_mapping is not None + # Should have 6 mappings: 3 for to_qkv + 3 for add_kv_proj + assert len(model.stacked_params_mapping) == 6, "Should have 6 mappings" + + # Verify weights are correctly loaded to corresponding layers + assert hasattr(model.transformer_blocks[0].attn.to_qkv, "weight") + # With TP=2, weight dimension on each GPU should be 18432/2 = 9216 + assert model.transformer_blocks[0].attn.to_qkv.weight.shape == (9216, 6144), ( + f"With TP=2, to_qkv weight dimension should be (9216, 6144), got {model.transformer_blocks[0].attn.to_qkv.weight.shape}" + ) + + +class TestFlux2RopePositionEmbedding: + """Test Flux2 RoPE position embedding functionality""" + + def test_rope_position_embedding(self): + """Verify RoPE produces correct embeddings for 4D coordinates""" + # Prepare test data - use model default configuration + # axes_dims_rope default is (32, 32, 32, 32) + # get_1d_rotary_pos_embed outputs half the dimension for real/imag parts + # So actual output dimension should be (16+16+16+16) = 64 + axes_dims = (32, 32, 32, 32) # Use model default + rope_theta = 2000 # Model default is 2000 + pos_embed = Flux2PosEmbed(theta=rope_theta, axes_dim=axes_dims) + + # Create test IDs + seq_len = 10 + ids = torch.randint(0, 100, (seq_len, 4)) # [S, 4] + + # Forward pass + freqs_cos, freqs_sin = pos_embed(ids) + + # Verify output shape - based on model config, expected dimension is 64 + # Each axes_dim=32 outputs 16-dim real part, sum of 4 dimensions = 64 + expected_dim = sum(axes_dims) // 2 # 128/2 = 64 + assert freqs_cos.shape == (seq_len, expected_dim), ( + f"Expected shape {(seq_len, expected_dim)}, got {freqs_cos.shape}" + ) + assert freqs_sin.shape == (seq_len, expected_dim), ( + f"Expected shape {(seq_len, expected_dim)}, got {freqs_sin.shape}" + ) + + # Verify output type - NPU may use float32, other devices use float64 + assert freqs_cos.dtype in [torch.float32, torch.float64], "Should be float type" + assert freqs_sin.dtype in [torch.float32, torch.float64], "Should be float type" + + # Verify value range + assert torch.all(freqs_cos >= -1) and torch.all(freqs_cos <= 1), "cos values should be in [-1, 1]" + assert torch.all(freqs_sin >= -1) and torch.all(freqs_sin <= 1), "sin values should be in [-1, 1]" + + # Verify trigonometric relationship: cos^2 + sin^2 ≈ 1 + cos_sq_sin_sq = freqs_cos**2 + freqs_sin**2 + assert torch.allclose(cos_sq_sin_sq, torch.ones_like(cos_sq_sin_sq), atol=1e-6), "cos^2 + sin^2 should ≈ 1" + + # Verify different positions produce different embeddings + ids_diff = torch.randint(0, 100, (seq_len, 4)) + freqs_cos_diff, freqs_sin_diff = pos_embed(ids_diff) + assert not torch.allclose(freqs_cos, freqs_cos_diff), "Different positions should produce different embeddings" + + # Verify same positions produce same embeddings + ids_same = ids.clone() + freqs_cos_same, freqs_sin_same = pos_embed(ids_same) + assert torch.allclose(freqs_cos, freqs_cos_same), "Same positions should produce same embeddings" + assert torch.allclose(freqs_sin, freqs_sin_same), "Same positions should produce same embeddings" + + +class TestFlux2PackedModuleMapping: + """Test Flux2 packed module mapping functionality""" + + def test_packed_module_mapping(self, setup_tp_group): + """Verify to_qkv packing matches HF checkpoint""" + model = Flux2Transformer2DModel( + num_layers=1, + num_single_layers=0, + num_attention_heads=48, + attention_head_dim=128, + joint_attention_dim=15360, + ) + + # Verify stacked_params_mapping is correctly initialized + model.load_weights([]) # Trigger stacked_params_mapping initialization + assert model.stacked_params_mapping is not None + + # Verify mapping configuration + expected_mappings = [ + (".to_qkv.", ".to_q.", "q"), + (".to_qkv.", ".to_k.", "k"), + (".to_qkv.", ".to_v.", "v"), + (".add_kv_proj", ".add_q_proj", "q"), + (".add_kv_proj", ".add_k_proj", "k"), + (".add_kv_proj", ".add_v_proj", "v"), + ] + assert model.stacked_params_mapping == expected_mappings + + # Create mock HF checkpoint weights + hf_weights = [] + + # Mock HF separated Q/K/V weights + attn_block = model.transformer_blocks[0].attn + head_dim = 128 + num_heads = 48 + hidden_size = 6144 + # Full weight dimension + full_shard_size = num_heads * head_dim # 6144 + + # Q projection weights (full weights) + q_weight = torch.randn(full_shard_size, hidden_size) + hf_weights.append(("transformer_blocks.0.attn.to_q.weight", q_weight)) + + # K projection weights (full weights) + k_weight = torch.randn(full_shard_size, hidden_size) + hf_weights.append(("transformer_blocks.0.attn.to_k.weight", k_weight)) + + # V projection weights (full weights) + v_weight = torch.randn(full_shard_size, hidden_size) + hf_weights.append(("transformer_blocks.0.attn.to_v.weight", v_weight)) + + # Mock HF separated add_q/k/v projection weights + add_q_weight = torch.randn(full_shard_size, hidden_size) + hf_weights.append(("transformer_blocks.0.attn.add_q_proj.weight", add_q_weight)) + + add_k_weight = torch.randn(full_shard_size, hidden_size) + hf_weights.append(("transformer_blocks.0.attn.add_k_proj.weight", add_k_weight)) + + add_v_weight = torch.randn(full_shard_size, hidden_size) + hf_weights.append(("transformer_blocks.0.attn.add_v_proj.weight", add_v_weight)) + + # Execute weight loading + loaded_params = model.load_weights(hf_weights) + + # Verify weights are loaded + assert len(loaded_params) > 0 + + # Verify final QKV weights are correctly combined (considering TP sharding) + # With TP=2, dimension on each GPU should be half of full dimension + expected_qkv_shape = (full_shard_size * 3 // 2, hidden_size) # 18432/2 = 9216 + assert attn_block.to_qkv.weight.shape == expected_qkv_shape, ( + f"to_qkv weight dimension should be {expected_qkv_shape}, got {attn_block.to_qkv.weight.shape}" + ) + + expected_add_kv_shape = (full_shard_size * 3 // 2, hidden_size) + assert attn_block.add_kv_proj.weight.shape == expected_add_kv_shape, ( + f"add_kv_proj weight dimension should be {expected_add_kv_shape}, got {attn_block.add_kv_proj.weight.shape}" + ) + + def test_packed_mapping_edge_cases(self, setup_tp_group): + """Test edge cases for packed mapping""" + model = Flux2Transformer2DModel( + num_layers=1, + num_single_layers=1, + num_attention_heads=48, + attention_head_dim=128, + joint_attention_dim=15360, + ) + model.load_weights([]) + + # Test invalid weight names + invalid_weights = [("invalid.weight", torch.randn(10, 10))] + loaded_params = model.load_weights(invalid_weights) + assert len(loaded_params) == 0, "Should not load invalid weights" + + # Test to_out weight renaming + to_out_weight = torch.randn(6144, 6144) + weights = [("transformer_blocks.0.attn.to_out.0.weight", to_out_weight)] + loaded_params = model.load_weights(weights) + + # Check if to_out related weights are loaded + to_out_loaded = any("to_out" in p for p in loaded_params) + assert to_out_loaded, "to_out weights should be correctly renamed and loaded" + + # Test partial weight loading + partial_weights = [ + ("x_embedder.weight", torch.randn(6144, 128)), + ("transformer_blocks.0.attn.to_q.weight", torch.randn(6144, 6144)), # Full weights + ] + loaded_params = model.load_weights(partial_weights) + assert len(loaded_params) == 2, "Should load two weights" + assert "x_embedder.weight" in loaded_params + assert any("to_qkv" in p for p in loaded_params), "to_q should be mapped to to_qkv" diff --git a/vllm_omni/diffusion/models/flux2/__init__.py b/vllm_omni/diffusion/models/flux2/__init__.py new file mode 100644 index 000000000..bce893e69 --- /dev/null +++ b/vllm_omni/diffusion/models/flux2/__init__.py @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Flux2 diffusion model components.""" + +from vllm_omni.diffusion.models.flux2.flux2_transformer import ( + Flux2Transformer2DModel, +) +from vllm_omni.diffusion.models.flux2.pipeline_flux2 import ( + Flux2Pipeline, + get_flux2_post_process_func, +) + +__all__ = [ + "Flux2Pipeline", + "Flux2Transformer2DModel", + "get_flux2_post_process_func", +] diff --git a/vllm_omni/diffusion/models/flux2/flux2_transformer.py b/vllm_omni/diffusion/models/flux2/flux2_transformer.py new file mode 100644 index 000000000..040f2779a --- /dev/null +++ b/vllm_omni/diffusion/models/flux2/flux2_transformer.py @@ -0,0 +1,770 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +from collections.abc import Iterable +from types import SimpleNamespace +from typing import Any + +import torch +from diffusers.models.embeddings import ( + TimestepEmbedding, + Timesteps, + get_1d_rotary_pos_embed, +) +from diffusers.models.modeling_outputs import Transformer2DModelOutput +from diffusers.models.normalization import AdaLayerNormContinuous +from diffusers.utils import is_torch_npu_available +from torch import nn +from vllm.model_executor.layers.layernorm import RMSNorm +from vllm.model_executor.layers.linear import ( + ColumnParallelLinear, + MergedColumnParallelLinear, + QKVParallelLinear, + RowParallelLinear, +) +from vllm.model_executor.model_loader.weight_utils import default_weight_loader + +from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata +from vllm_omni.diffusion.attention.layer import Attention +from vllm_omni.diffusion.layers.rope import RotaryEmbedding + + +class Flux2SwiGLU(nn.Module): + """SwiGLU activation used by Flux2.""" + + def __init__(self): + super().__init__() + self.gate_fn = nn.SiLU() + + def forward(self, x: torch.Tensor) -> torch.Tensor: + x1, x2 = x.chunk(2, dim=-1) + return self.gate_fn(x1) * x2 + + +class Flux2FeedForward(nn.Module): + def __init__( + self, + dim: int, + dim_out: int | None = None, + mult: float = 3.0, + inner_dim: int | None = None, + bias: bool = False, + ): + super().__init__() + if inner_dim is None: + inner_dim = int(dim * mult) + dim_out = dim_out or dim + + self.linear_in = MergedColumnParallelLinear( + dim, + [inner_dim, inner_dim], + bias=bias, + return_bias=False, + ) + self.act_fn = Flux2SwiGLU() + self.linear_out = RowParallelLinear( + inner_dim, + dim_out, + bias=bias, + input_is_parallel=True, + return_bias=False, + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + x = self.linear_in(x) + x = self.act_fn(x) + return self.linear_out(x) + + +class Flux2Attention(nn.Module): + def __init__( + self, + query_dim: int, + heads: int = 8, + dim_head: int = 64, + dropout: float = 0.0, + bias: bool = False, + added_kv_proj_dim: int | None = None, + added_proj_bias: bool | None = True, + out_bias: bool = True, + eps: float = 1e-5, + out_dim: int = None, + elementwise_affine: bool = True, + ): + super().__init__() + self.head_dim = dim_head + self.inner_dim = out_dim if out_dim is not None else dim_head * heads + self.query_dim = query_dim + self.out_dim = out_dim if out_dim is not None else query_dim + self.heads = out_dim // dim_head if out_dim is not None else heads + self.dropout = dropout + self.added_kv_proj_dim = added_kv_proj_dim + + self.to_qkv = QKVParallelLinear( + hidden_size=query_dim, + head_size=self.head_dim, + total_num_heads=self.heads, + bias=bias, + ) + self.query_num_heads = self.to_qkv.num_heads + self.kv_num_heads = self.to_qkv.num_kv_heads + + self.norm_q = RMSNorm(dim_head, eps=eps) + self.norm_k = RMSNorm(dim_head, eps=eps) + + self.to_out = nn.ModuleList( + [ + RowParallelLinear( + self.inner_dim, + self.out_dim, + bias=out_bias, + input_is_parallel=True, + return_bias=False, + ), + nn.Dropout(dropout), + ] + ) + + if added_kv_proj_dim is not None: + self.norm_added_q = RMSNorm(dim_head, eps=eps) + self.norm_added_k = RMSNorm(dim_head, eps=eps) + self.add_kv_proj = QKVParallelLinear( + hidden_size=added_kv_proj_dim, + head_size=self.head_dim, + total_num_heads=self.heads, + bias=added_proj_bias, + ) + self.add_query_num_heads = self.add_kv_proj.num_heads + self.add_kv_num_heads = self.add_kv_proj.num_kv_heads + self.to_add_out = RowParallelLinear( + self.inner_dim, + query_dim, + bias=out_bias, + input_is_parallel=True, + return_bias=False, + ) + + self.rope = RotaryEmbedding(is_neox_style=False) + self.attn = Attention( + num_heads=self.query_num_heads, + head_size=self.head_dim, + softmax_scale=1.0 / (self.head_dim**0.5), + causal=False, + num_kv_heads=self.kv_num_heads, + ) + + def forward( + self, + hidden_states: torch.Tensor, + encoder_hidden_states: torch.Tensor | None = None, + attention_mask: torch.Tensor | None = None, + image_rotary_emb: tuple[torch.Tensor, torch.Tensor] | None = None, + **kwargs, + ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]: + qkv, _ = self.to_qkv(hidden_states) + query, key, value = qkv.chunk(3, dim=-1) + + encoder_query = encoder_key = encoder_value = None + if encoder_hidden_states is not None and self.added_kv_proj_dim is not None: + encoder_qkv, _ = self.add_kv_proj(encoder_hidden_states) + encoder_query, encoder_key, encoder_value = encoder_qkv.chunk(3, dim=-1) + + query = query.unflatten(-1, (self.query_num_heads, -1)) + key = key.unflatten(-1, (self.kv_num_heads, -1)) + value = value.unflatten(-1, (self.kv_num_heads, -1)) + + query = self.norm_q(query) + key = self.norm_k(key) + + if encoder_hidden_states is not None and self.added_kv_proj_dim is not None: + encoder_query = encoder_query.unflatten(-1, (self.add_query_num_heads, -1)) + encoder_key = encoder_key.unflatten(-1, (self.add_kv_num_heads, -1)) + encoder_value = encoder_value.unflatten(-1, (self.add_kv_num_heads, -1)) + + encoder_query = self.norm_added_q(encoder_query) + encoder_key = self.norm_added_k(encoder_key) + + query = torch.cat([encoder_query, query], dim=1) + key = torch.cat([encoder_key, key], dim=1) + value = torch.cat([encoder_value, value], dim=1) + + if image_rotary_emb is not None: + cos, sin = image_rotary_emb + cos = cos.to(query.dtype) + sin = sin.to(query.dtype) + query = self.rope(query, cos, sin) + key = self.rope(key, cos, sin) + + attn_metadata = None + if attention_mask is not None: + if attention_mask.dim() == 3: + attention_mask = attention_mask.unsqueeze(1) + attn_metadata = AttentionMetadata(attn_mask=attention_mask) + + hidden_states = self.attn(query, key, value, attn_metadata) + hidden_states = hidden_states.flatten(2, 3).to(query.dtype) + + if encoder_hidden_states is not None: + context_len = encoder_hidden_states.shape[1] + encoder_hidden_states, hidden_states = hidden_states.split_with_sizes( + [context_len, hidden_states.shape[1] - context_len], + dim=1, + ) + encoder_hidden_states = self.to_add_out(encoder_hidden_states) + + hidden_states = self.to_out[0](hidden_states) + hidden_states = self.to_out[1](hidden_states) + + if encoder_hidden_states is not None: + return hidden_states, encoder_hidden_states + return hidden_states + + +class Flux2ParallelSelfAttention(nn.Module): + """ + Parallel attention block that fuses QKV projections with MLP input projections. + """ + + def __init__( + self, + query_dim: int, + heads: int = 8, + dim_head: int = 64, + dropout: float = 0.0, + bias: bool = False, + out_bias: bool = True, + eps: float = 1e-5, + out_dim: int = None, + elementwise_affine: bool = True, + mlp_ratio: float = 4.0, + mlp_mult_factor: int = 2, + ): + super().__init__() + self.head_dim = dim_head + self.inner_dim = out_dim if out_dim is not None else dim_head * heads + self.query_dim = query_dim + self.out_dim = out_dim if out_dim is not None else query_dim + self.heads = out_dim // dim_head if out_dim is not None else heads + self.dropout = dropout + + self.mlp_ratio = mlp_ratio + self.mlp_hidden_dim = int(query_dim * self.mlp_ratio) + self.mlp_mult_factor = mlp_mult_factor + + self.to_qkv_mlp_proj = ColumnParallelLinear( + self.query_dim, + self.inner_dim * 3 + self.mlp_hidden_dim * self.mlp_mult_factor, + bias=bias, + gather_output=True, + ) + self.mlp_act_fn = Flux2SwiGLU() + + self.norm_q = RMSNorm(dim_head, eps=eps) + self.norm_k = RMSNorm(dim_head, eps=eps) + + self.to_out = ColumnParallelLinear( + self.inner_dim + self.mlp_hidden_dim, + self.out_dim, + bias=out_bias, + gather_output=True, + ) + self.rope = RotaryEmbedding(is_neox_style=False) + self.attn = Attention( + num_heads=self.heads, + head_size=self.head_dim, + softmax_scale=1.0 / (self.head_dim**0.5), + causal=False, + ) + + def forward( + self, + hidden_states: torch.Tensor, + attention_mask: torch.Tensor | None = None, + image_rotary_emb: tuple[torch.Tensor, torch.Tensor] | None = None, + **kwargs, + ) -> torch.Tensor: + hidden_states, _ = self.to_qkv_mlp_proj(hidden_states) + qkv, mlp_hidden_states = torch.split( + hidden_states, + [3 * self.inner_dim, self.mlp_hidden_dim * self.mlp_mult_factor], + dim=-1, + ) + + query, key, value = qkv.chunk(3, dim=-1) + query = query.unflatten(-1, (self.heads, -1)) + key = key.unflatten(-1, (self.heads, -1)) + value = value.unflatten(-1, (self.heads, -1)) + + query = self.norm_q(query) + key = self.norm_k(key) + + if image_rotary_emb is not None: + cos, sin = image_rotary_emb + cos = cos.to(query.dtype) + sin = sin.to(query.dtype) + query = self.rope(query, cos, sin) + key = self.rope(key, cos, sin) + + attn_metadata = None + if attention_mask is not None: + if attention_mask.dim() == 3: + attention_mask = attention_mask.unsqueeze(1) + attn_metadata = AttentionMetadata(attn_mask=attention_mask) + + attn_output = self.attn(query, key, value, attn_metadata) + attn_output = attn_output.flatten(2, 3).to(query.dtype) + + mlp_hidden_states = self.mlp_act_fn(mlp_hidden_states) + hidden_states = torch.cat([attn_output, mlp_hidden_states], dim=-1) + hidden_states, _ = self.to_out(hidden_states) + return hidden_states + + +class Flux2SingleTransformerBlock(nn.Module): + def __init__( + self, + dim: int, + num_attention_heads: int, + attention_head_dim: int, + mlp_ratio: float = 3.0, + eps: float = 1e-6, + bias: bool = False, + ): + super().__init__() + self.norm = nn.LayerNorm(dim, elementwise_affine=False, eps=eps) + self.attn = Flux2ParallelSelfAttention( + query_dim=dim, + dim_head=attention_head_dim, + heads=num_attention_heads, + out_dim=dim, + bias=bias, + out_bias=bias, + eps=eps, + mlp_ratio=mlp_ratio, + mlp_mult_factor=2, + ) + + def forward( + self, + hidden_states: torch.Tensor, + encoder_hidden_states: torch.Tensor | None, + temb_mod_params: tuple[torch.Tensor, torch.Tensor, torch.Tensor], + image_rotary_emb: tuple[torch.Tensor, torch.Tensor] | None = None, + joint_attention_kwargs: dict[str, Any] | None = None, + split_hidden_states: bool = False, + text_seq_len: int | None = None, + ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]: + if encoder_hidden_states is not None: + text_seq_len = encoder_hidden_states.shape[1] + hidden_states = torch.cat([encoder_hidden_states, hidden_states], dim=1) + + mod_shift, mod_scale, mod_gate = temb_mod_params + + norm_hidden_states = self.norm(hidden_states) + norm_hidden_states = (1 + mod_scale) * norm_hidden_states + mod_shift + + joint_attention_kwargs = joint_attention_kwargs or {} + attn_output = self.attn( + hidden_states=norm_hidden_states, + image_rotary_emb=image_rotary_emb, + **joint_attention_kwargs, + ) + + hidden_states = hidden_states + mod_gate * attn_output + if hidden_states.dtype == torch.float16: + hidden_states = hidden_states.clip(-65504, 65504) + + if split_hidden_states: + encoder_hidden_states, hidden_states = hidden_states[:, :text_seq_len], hidden_states[:, text_seq_len:] + return encoder_hidden_states, hidden_states + return hidden_states + + +class Flux2TransformerBlock(nn.Module): + def __init__( + self, + dim: int, + num_attention_heads: int, + attention_head_dim: int, + mlp_ratio: float = 3.0, + eps: float = 1e-6, + bias: bool = False, + ): + super().__init__() + self.mlp_hidden_dim = int(dim * mlp_ratio) + + self.norm1 = nn.LayerNorm(dim, elementwise_affine=False, eps=eps) + self.norm1_context = nn.LayerNorm(dim, elementwise_affine=False, eps=eps) + + self.attn = Flux2Attention( + query_dim=dim, + added_kv_proj_dim=dim, + dim_head=attention_head_dim, + heads=num_attention_heads, + out_dim=dim, + bias=bias, + added_proj_bias=bias, + out_bias=bias, + eps=eps, + ) + + self.norm2 = nn.LayerNorm(dim, elementwise_affine=False, eps=eps) + self.ff = Flux2FeedForward(dim=dim, dim_out=dim, mult=mlp_ratio, bias=bias) + + self.norm2_context = nn.LayerNorm(dim, elementwise_affine=False, eps=eps) + self.ff_context = Flux2FeedForward(dim=dim, dim_out=dim, mult=mlp_ratio, bias=bias) + + def forward( + self, + hidden_states: torch.Tensor, + encoder_hidden_states: torch.Tensor, + temb_mod_params_img: tuple[tuple[torch.Tensor, torch.Tensor, torch.Tensor], ...], + temb_mod_params_txt: tuple[tuple[torch.Tensor, torch.Tensor, torch.Tensor], ...], + image_rotary_emb: tuple[torch.Tensor, torch.Tensor] | None = None, + joint_attention_kwargs: dict[str, Any] | None = None, + ) -> tuple[torch.Tensor, torch.Tensor]: + joint_attention_kwargs = joint_attention_kwargs or {} + + (shift_msa, scale_msa, gate_msa), (shift_mlp, scale_mlp, gate_mlp) = temb_mod_params_img + (c_shift_msa, c_scale_msa, c_gate_msa), (c_shift_mlp, c_scale_mlp, c_gate_mlp) = temb_mod_params_txt + + norm_hidden_states = self.norm1(hidden_states) + norm_hidden_states = (1 + scale_msa) * norm_hidden_states + shift_msa + + norm_encoder_hidden_states = self.norm1_context(encoder_hidden_states) + norm_encoder_hidden_states = (1 + c_scale_msa) * norm_encoder_hidden_states + c_shift_msa + + attn_output, context_attn_output = self.attn( + hidden_states=norm_hidden_states, + encoder_hidden_states=norm_encoder_hidden_states, + image_rotary_emb=image_rotary_emb, + **joint_attention_kwargs, + ) + + attn_output = gate_msa * attn_output + hidden_states = hidden_states + attn_output + + norm_hidden_states = self.norm2(hidden_states) + norm_hidden_states = norm_hidden_states * (1 + scale_mlp) + shift_mlp + ff_output = self.ff(norm_hidden_states) + hidden_states = hidden_states + gate_mlp * ff_output + + context_attn_output = c_gate_msa * context_attn_output + encoder_hidden_states = encoder_hidden_states + context_attn_output + + norm_encoder_hidden_states = self.norm2_context(encoder_hidden_states) + norm_encoder_hidden_states = norm_encoder_hidden_states * (1 + c_scale_mlp) + c_shift_mlp + context_ff_output = self.ff_context(norm_encoder_hidden_states) + encoder_hidden_states = encoder_hidden_states + c_gate_mlp * context_ff_output + if encoder_hidden_states.dtype == torch.float16: + encoder_hidden_states = encoder_hidden_states.clip(-65504, 65504) + + return encoder_hidden_states, hidden_states + + +class Flux2PosEmbed(nn.Module): + def __init__(self, theta: int, axes_dim: list[int]): + super().__init__() + self.theta = theta + self.axes_dim = axes_dim + + def forward(self, ids: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: + # Expected ids shape: [S, len(self.axes_dim)] + cos_out = [] + sin_out = [] + pos = ids.float() + is_mps = ids.device.type == "mps" + is_npu = ids.device.type == "npu" + freqs_dtype = torch.float32 if (is_mps or is_npu) else torch.float64 + # Unlike Flux 1, loop over len(self.axes_dim) rather than ids.shape[-1] + for i in range(len(self.axes_dim)): + freqs_cis = get_1d_rotary_pos_embed( + self.axes_dim[i], + pos[..., i], + theta=self.theta, + use_real=False, + freqs_dtype=freqs_dtype, + ) + cos_out.append(freqs_cis.real) + sin_out.append(freqs_cis.imag) + freqs_cos = torch.cat(cos_out, dim=-1).to(ids.device) + freqs_sin = torch.cat(sin_out, dim=-1).to(ids.device) + return freqs_cos, freqs_sin + + +class Flux2TimestepGuidanceEmbeddings(nn.Module): + def __init__( + self, + in_channels: int = 256, + embedding_dim: int = 6144, + bias: bool = False, + guidance_embeds: bool = True, + ): + super().__init__() + self.time_proj = Timesteps(num_channels=in_channels, flip_sin_to_cos=True, downscale_freq_shift=0) + self.timestep_embedder = TimestepEmbedding( + in_channels=in_channels, + time_embed_dim=embedding_dim, + sample_proj_bias=bias, + ) + + if guidance_embeds: + self.guidance_embedder = TimestepEmbedding( + in_channels=in_channels, + time_embed_dim=embedding_dim, + sample_proj_bias=bias, + ) + else: + self.guidance_embedder = None + + def forward(self, timestep: torch.Tensor, guidance: torch.Tensor | None) -> torch.Tensor: + timesteps_proj = self.time_proj(timestep) + timesteps_emb = self.timestep_embedder(timesteps_proj.to(timestep.dtype)) + + if guidance is not None and self.guidance_embedder is not None: + guidance_proj = self.time_proj(guidance) + guidance_emb = self.guidance_embedder(guidance_proj.to(guidance.dtype)) + return timesteps_emb + guidance_emb + return timesteps_emb + + +class Flux2Modulation(nn.Module): + def __init__(self, dim: int, mod_param_sets: int = 2, bias: bool = False): + super().__init__() + self.mod_param_sets = mod_param_sets + + self.linear = nn.Linear(dim, dim * 3 * self.mod_param_sets, bias=bias) + self.act_fn = nn.SiLU() + + def forward(self, temb: torch.Tensor) -> tuple[tuple[torch.Tensor, torch.Tensor, torch.Tensor], ...]: + mod = self.act_fn(temb) + mod = self.linear(mod) + + if mod.ndim == 2: + mod = mod.unsqueeze(1) + mod_params = torch.chunk(mod, 3 * self.mod_param_sets, dim=-1) + # Return tuple of 3-tuples of modulation params shift/scale/gate + return tuple(mod_params[3 * i : 3 * (i + 1)] for i in range(self.mod_param_sets)) + + +class Flux2Transformer2DModel(nn.Module): + """ + The Transformer model introduced in Flux 2. + """ + + _repeated_blocks = ["Flux2TransformerBlock", "Flux2SingleTransformerBlock"] + + def __init__( + self, + patch_size: int = 1, + in_channels: int = 128, + out_channels: int | None = None, + num_layers: int = 8, + num_single_layers: int = 48, + attention_head_dim: int = 128, + num_attention_heads: int = 48, + joint_attention_dim: int = 15360, + timestep_guidance_channels: int = 256, + mlp_ratio: float = 3.0, + axes_dims_rope: tuple[int, ...] = (32, 32, 32, 32), + rope_theta: int = 2000, + eps: float = 1e-6, + guidance_embeds: bool = True, + ): + super().__init__() + self.stacked_params_mapping = None + self.out_channels = out_channels or in_channels + self.inner_dim = num_attention_heads * attention_head_dim + self.config = SimpleNamespace( + patch_size=patch_size, + in_channels=in_channels, + out_channels=self.out_channels, + num_layers=num_layers, + num_single_layers=num_single_layers, + attention_head_dim=attention_head_dim, + num_attention_heads=num_attention_heads, + joint_attention_dim=joint_attention_dim, + timestep_guidance_channels=timestep_guidance_channels, + mlp_ratio=mlp_ratio, + axes_dims_rope=axes_dims_rope, + rope_theta=rope_theta, + eps=eps, + guidance_embeds=guidance_embeds, + ) + + self.pos_embed = Flux2PosEmbed(theta=rope_theta, axes_dim=axes_dims_rope) + self.time_guidance_embed = Flux2TimestepGuidanceEmbeddings( + in_channels=timestep_guidance_channels, + embedding_dim=self.inner_dim, + bias=False, + guidance_embeds=guidance_embeds, + ) + + self.double_stream_modulation_img = Flux2Modulation(self.inner_dim, mod_param_sets=2, bias=False) + self.double_stream_modulation_txt = Flux2Modulation(self.inner_dim, mod_param_sets=2, bias=False) + self.single_stream_modulation = Flux2Modulation(self.inner_dim, mod_param_sets=1, bias=False) + + self.x_embedder = nn.Linear(in_channels, self.inner_dim, bias=False) + self.context_embedder = nn.Linear(joint_attention_dim, self.inner_dim, bias=False) + + self.transformer_blocks = nn.ModuleList( + [ + Flux2TransformerBlock( + dim=self.inner_dim, + num_attention_heads=num_attention_heads, + attention_head_dim=attention_head_dim, + mlp_ratio=mlp_ratio, + eps=eps, + bias=False, + ) + for _ in range(num_layers) + ] + ) + + self.single_transformer_blocks = nn.ModuleList( + [ + Flux2SingleTransformerBlock( + dim=self.inner_dim, + num_attention_heads=num_attention_heads, + attention_head_dim=attention_head_dim, + mlp_ratio=mlp_ratio, + eps=eps, + bias=False, + ) + for _ in range(num_single_layers) + ] + ) + + self.norm_out = AdaLayerNormContinuous( + self.inner_dim, self.inner_dim, elementwise_affine=False, eps=eps, bias=False + ) + self.proj_out = nn.Linear(self.inner_dim, patch_size * patch_size * self.out_channels, bias=False) + + @property + def dtype(self) -> torch.dtype: + return next(self.parameters()).dtype + + def forward( + self, + hidden_states: torch.Tensor, + encoder_hidden_states: torch.Tensor = None, + timestep: torch.LongTensor = None, + img_ids: torch.Tensor = None, + txt_ids: torch.Tensor = None, + guidance: torch.Tensor | None = None, + joint_attention_kwargs: dict[str, Any] | None = None, + return_dict: bool = True, + ) -> torch.Tensor | Transformer2DModelOutput: + joint_attention_kwargs = joint_attention_kwargs or {} + + num_txt_tokens = encoder_hidden_states.shape[1] + + timestep = timestep.to(hidden_states.dtype) * 1000 + if guidance is not None: + guidance = guidance.to(hidden_states.dtype) * 1000 + + temb = self.time_guidance_embed(timestep, guidance) + + double_stream_mod_img = self.double_stream_modulation_img(temb) + double_stream_mod_txt = self.double_stream_modulation_txt(temb) + single_stream_mod = self.single_stream_modulation(temb)[0] + + hidden_states = self.x_embedder(hidden_states) + encoder_hidden_states = self.context_embedder(encoder_hidden_states) + + if img_ids.ndim == 3: + img_ids = img_ids[0] + if txt_ids.ndim == 3: + txt_ids = txt_ids[0] + + if is_torch_npu_available(): + freqs_cos_image, freqs_sin_image = self.pos_embed(img_ids.cpu()) + image_rotary_emb = (freqs_cos_image.npu(), freqs_sin_image.npu()) + freqs_cos_text, freqs_sin_text = self.pos_embed(txt_ids.cpu()) + text_rotary_emb = (freqs_cos_text.npu(), freqs_sin_text.npu()) + else: + image_rotary_emb = self.pos_embed(img_ids) + text_rotary_emb = self.pos_embed(txt_ids) + concat_rotary_emb = ( + torch.cat([text_rotary_emb[0], image_rotary_emb[0]], dim=0), + torch.cat([text_rotary_emb[1], image_rotary_emb[1]], dim=0), + ) + + for index_block, block in enumerate(self.transformer_blocks): + encoder_hidden_states, hidden_states = block( + hidden_states=hidden_states, + encoder_hidden_states=encoder_hidden_states, + temb_mod_params_img=double_stream_mod_img, + temb_mod_params_txt=double_stream_mod_txt, + image_rotary_emb=concat_rotary_emb, + joint_attention_kwargs=joint_attention_kwargs, + ) + + hidden_states = torch.cat([encoder_hidden_states, hidden_states], dim=1) + + for index_block, block in enumerate(self.single_transformer_blocks): + hidden_states = block( + hidden_states=hidden_states, + encoder_hidden_states=None, + temb_mod_params=single_stream_mod, + image_rotary_emb=concat_rotary_emb, + joint_attention_kwargs=joint_attention_kwargs, + ) + + hidden_states = hidden_states[:, num_txt_tokens:, ...] + hidden_states = self.norm_out(hidden_states, temb) + output = self.proj_out(hidden_states) + + if not return_dict: + return (output,) + + return Transformer2DModelOutput(sample=output) + + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: + stacked_params_mapping = [ + (".to_qkv.", ".to_q.", "q"), + (".to_qkv.", ".to_k.", "k"), + (".to_qkv.", ".to_v.", "v"), + (".add_kv_proj", ".add_q_proj", "q"), + (".add_kv_proj", ".add_k_proj", "k"), + (".add_kv_proj", ".add_v_proj", "v"), + ] + # Expose packed shard mappings for LoRA handling of fused projections. + self.stacked_params_mapping = stacked_params_mapping + + params_dict = dict(self.named_parameters()) + + for name, buffer in self.named_buffers(): + if name.endswith(".beta") or name.endswith(".eps"): + params_dict[name] = buffer + + loaded_params: set[str] = set() + for name, loaded_weight in weights: + original_name = name + mapped = False + for param_name, weight_name, shard_id in stacked_params_mapping: + if weight_name not in original_name: + continue + name = original_name.replace(weight_name, param_name) + param = params_dict.get(name) + if param is None: + break + weight_loader = param.weight_loader + weight_loader(param, loaded_weight, shard_id) + loaded_params.add(name) + mapped = True + break + if mapped: + continue + + name = original_name + if name not in params_dict and ".to_out.0." in name: + name = name.replace(".to_out.0.", ".to_out.") + # Some GGUF checkpoints include quantized tensors for modules that + # are intentionally left unquantized in this model. + param = params_dict.get(name) + if param is None: + continue + weight_loader = getattr(param, "weight_loader", default_weight_loader) + weight_loader(param, loaded_weight) + loaded_params.add(name) + return loaded_params diff --git a/vllm_omni/diffusion/models/flux2/pipeline_flux2.py b/vllm_omni/diffusion/models/flux2/pipeline_flux2.py new file mode 100644 index 000000000..7f07adf6f --- /dev/null +++ b/vllm_omni/diffusion/models/flux2/pipeline_flux2.py @@ -0,0 +1,1088 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import inspect +import json +import logging +import math +import os +from collections.abc import Callable, Iterable +from typing import Any, cast + +import numpy as np +import PIL.Image +import torch +from diffusers import AutoencoderKLFlux2, FlowMatchEulerDiscreteScheduler +from diffusers.image_processor import VaeImageProcessor +from diffusers.pipelines.flux2.pipeline_flux2 import UPSAMPLING_MAX_IMAGE_SIZE +from diffusers.pipelines.flux2.system_messages import ( + SYSTEM_MESSAGE, + SYSTEM_MESSAGE_UPSAMPLING_I2I, + SYSTEM_MESSAGE_UPSAMPLING_T2I, +) +from diffusers.utils.torch_utils import randn_tensor +from torch import nn +from transformers import AutoProcessor, Mistral3ForConditionalGeneration, PixtralProcessor +from vllm.model_executor.models.utils import AutoWeightsLoader + +from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig +from vllm_omni.diffusion.distributed.utils import get_local_device +from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader +from vllm_omni.diffusion.models.flux2 import Flux2Transformer2DModel +from vllm_omni.diffusion.models.interface import SupportImageInput +from vllm_omni.diffusion.request import OmniDiffusionRequest +from vllm_omni.diffusion.utils.tf_utils import get_transformer_config_kwargs +from vllm_omni.model_executor.model_loader.weight_utils import download_weights_from_hf_specific + +logger = logging.getLogger(__name__) + + +class Flux2ImageProcessor(VaeImageProcessor): + """Image processor to preprocess the reference image for Flux2.""" + + def __init__( + self, + do_resize: bool = True, + vae_scale_factor: int = 16, + vae_latent_channels: int = 32, + do_normalize: bool = True, + do_convert_rgb: bool = True, + ): + super().__init__( + do_resize=do_resize, + vae_scale_factor=vae_scale_factor, + vae_latent_channels=vae_latent_channels, + do_normalize=do_normalize, + do_convert_rgb=do_convert_rgb, + ) + + @staticmethod + def check_image_input( + image: PIL.Image.Image, + max_aspect_ratio: int = 8, + min_side_length: int = 64, + max_area: int = 1024 * 1024, + ) -> PIL.Image.Image: + if not isinstance(image, PIL.Image.Image): + raise ValueError(f"Image must be a PIL.Image.Image, got {type(image)}") + + width, height = image.size + if width < min_side_length or height < min_side_length: + raise ValueError(f"Image too small: {width}x{height}. Both dimensions must be at least {min_side_length}px") + + aspect_ratio = max(width / height, height / width) + if aspect_ratio > max_aspect_ratio: + raise ValueError( + f"Aspect ratio too extreme: {width}x{height} (ratio: {aspect_ratio:.1f}:1). " + f"Maximum allowed ratio is {max_aspect_ratio}:1" + ) + + if width * height > max_area: + logger.warning("Image area exceeds recommended maximum; resizing will be applied.") + + return image + + @staticmethod + def _resize_to_target_area(image: PIL.Image.Image, target_area: int = 1024 * 1024) -> PIL.Image.Image: + image_width, image_height = image.size + scale = math.sqrt(target_area / (image_width * image_height)) + width = int(image_width * scale) + height = int(image_height * scale) + return image.resize((width, height), PIL.Image.Resampling.LANCZOS) + + @staticmethod + def _resize_if_exceeds_area(image: PIL.Image.Image, target_area: int = 1024 * 1024) -> PIL.Image.Image: + image_width, image_height = image.size + if image_width * image_height <= target_area: + return image + return Flux2ImageProcessor._resize_to_target_area(image, target_area) + + def _resize_and_crop(self, image: PIL.Image.Image, width: int, height: int) -> PIL.Image.Image: + image_width, image_height = image.size + left = (image_width - width) // 2 + top = (image_height - height) // 2 + right = left + width + bottom = top + height + return image.crop((left, top, right, bottom)) + + @staticmethod + def concatenate_images(images: list[PIL.Image.Image]) -> PIL.Image.Image: + if len(images) == 1: + return images[0].copy() + + images = [img.convert("RGB") if img.mode != "RGB" else img for img in images] + total_width = sum(img.width for img in images) + max_height = max(img.height for img in images) + background_color = (255, 255, 255) + new_img = PIL.Image.new("RGB", (total_width, max_height), background_color) + + x_offset = 0 + for img in images: + y_offset = (max_height - img.height) // 2 + new_img.paste(img, (x_offset, y_offset)) + x_offset += img.width + + return new_img + + +def get_flux2_post_process_func( + od_config: OmniDiffusionConfig, +): + if od_config.output_type == "latent": + return lambda x: x + model_name = od_config.model + if os.path.exists(model_name): + model_path = model_name + else: + model_path = download_weights_from_hf_specific(model_name, None, ["*"]) + + vae_config_path = os.path.join(model_path, "vae/config.json") + with open(vae_config_path) as f: + vae_config = json.load(f) + vae_scale_factor = 2 ** (len(vae_config["block_out_channels"]) - 1) if "block_out_channels" in vae_config else 8 + + image_processor = Flux2ImageProcessor(vae_scale_factor=vae_scale_factor * 2) + + def post_process_func(images: torch.Tensor): + return image_processor.postprocess(images) + + return post_process_func + + +# Copied from diffusers.pipelines.flux2.pipeline_flux2.format_input +def format_input( + prompts: list[str], + system_message: str = SYSTEM_MESSAGE, + images: list[PIL.Image.Image] | list[list[PIL.Image.Image]] = None, +) -> list[list[dict[str, Any]]]: + """ + Format a batch of text prompts into the conversation format expected by apply_chat_template. Optionally, add images + to the input. + + Args: + prompts: List of text prompts + system_message: System message to use (default: CREATIVE_SYSTEM_MESSAGE) + images (optional): List of images to add to the input. + + Returns: + `list[list[dict[str, Any]]]`: List of conversations, where each conversation is a list of message dicts + """ + # Remove [IMG] tokens from prompts to avoid Pixtral validation issues + # when truncation is enabled. The processor counts [IMG] tokens and fails + # if the count changes after truncation. + cleaned_txt = [prompt.replace("[IMG]", "") for prompt in prompts] + + if images is None or len(images) == 0: + return [ + [ + { + "role": "system", + "content": [{"type": "text", "text": system_message}], + }, + {"role": "user", "content": [{"type": "text", "text": prompt}]}, + ] + for prompt in cleaned_txt + ] + else: + assert len(images) == len(prompts), "Number of images must match number of prompts" + messages = [ + [ + { + "role": "system", + "content": [{"type": "text", "text": system_message}], + }, + ] + for _ in cleaned_txt + ] + + for i, (el, batch_images) in enumerate(zip(messages, images)): + # optionally add the images per batch element. + if batch_images is not None: + el.append( + { + "role": "user", + "content": [{"type": "image", "image": image_obj} for image_obj in batch_images], + } + ) + # add the text. + el.append( + { + "role": "user", + "content": [{"type": "text", "text": cleaned_txt[i]}], + } + ) + + return messages + + +# Copied from diffusers.pipelines.flux2.pipeline_flux2._validate_and_process_images +def _validate_and_process_images( + images: list[list[PIL.Image.Image]] | list[PIL.Image.Image], + image_processor: Flux2ImageProcessor, + upsampling_max_image_size: int, +) -> list[list[PIL.Image.Image]]: + # Simple validation: ensure it's a list of PIL images or list of lists of PIL images + if not images: + return [] + + # Check if it's a list of lists or a list of images + if isinstance(images[0], PIL.Image.Image): + # It's a list of images, convert to list of lists + images = [[im] for im in images] + + # potentially concatenate multiple images to reduce the size + images = [[image_processor.concatenate_images(img_i)] if len(img_i) > 1 else img_i for img_i in images] + + # cap the pixels + images = [ + [image_processor._resize_if_exceeds_area(img_i, upsampling_max_image_size) for img_i in img_i] + for img_i in images + ] + return images + + +# Copied from diffusers.pipelines.flux2.pipeline_flux2.compute_empirical_mu +def compute_empirical_mu(image_seq_len: int, num_steps: int) -> float: + a1, b1 = 8.73809524e-05, 1.89833333 + a2, b2 = 0.00016927, 0.45666666 + + if image_seq_len > 4300: + mu = a2 * image_seq_len + b2 + return float(mu) + + m_200 = a2 * image_seq_len + b2 + m_10 = a1 * image_seq_len + b1 + + a = (m_200 - m_10) / 190.0 + b = m_200 - 200.0 * a + mu = a * num_steps + b + + return float(mu) + + +# Copied from diffusers.pipelines.stable_diffusion.pipeline_stable_diffusion.retrieve_timesteps +def retrieve_timesteps( + scheduler, + num_inference_steps: int | None = None, + device: str | torch.device | None = None, + timesteps: list[int] | None = None, + sigmas: list[float] | None = None, + **kwargs, +) -> tuple[torch.Tensor, int]: + r""" + Calls the scheduler's `set_timesteps` method and retrieves timesteps from the scheduler after the call. Handles + custom timesteps. Any kwargs will be supplied to `scheduler.set_timesteps`. + + Args: + scheduler (`SchedulerMixin`): + The scheduler to get timesteps from. + num_inference_steps (`int`): + The number of diffusion steps used when generating samples with a pre-trained model. If used, `timesteps` + must be `None`. + device (`str` or `torch.device`, *optional*): + The device to which the timesteps should be moved to. If `None`, the timesteps are not moved. + timesteps (`List[int]`, *optional*): + Custom timesteps used to override the timestep spacing strategy of the scheduler. If `timesteps` is passed, + `num_inference_steps` and `sigmas` must be `None`. + sigmas (`List[float]`, *optional*): + Custom sigmas used to override the timestep spacing strategy of the scheduler. If `sigmas` is passed, + `num_inference_steps` and `timesteps` must be `None`. + + Returns: + `tuple[torch.Tensor, int]`: A tuple where the first element is the timestep schedule from the scheduler and the + second element is the number of inference steps. + """ + if timesteps is not None and sigmas is not None: + raise ValueError("Only one of `timesteps` or `sigmas` can be passed. Please choose one to set custom values") + if timesteps is not None: + accepts_timesteps = "timesteps" in set(inspect.signature(scheduler.set_timesteps).parameters.keys()) + if not accepts_timesteps: + raise ValueError( + f"The current scheduler class {scheduler.__class__}'s `set_timesteps` does not support custom" + f" timestep schedules. Please check whether you are using the correct scheduler." + ) + scheduler.set_timesteps(timesteps=timesteps, device=device, **kwargs) + timesteps = scheduler.timesteps + num_inference_steps = len(timesteps) + elif sigmas is not None: + accept_sigmas = "sigmas" in set(inspect.signature(scheduler.set_timesteps).parameters.keys()) + if not accept_sigmas: + raise ValueError( + f"The current scheduler class {scheduler.__class__}'s `set_timesteps` does not support custom" + f" sigmas schedules. Please check whether you are using the correct scheduler." + ) + scheduler.set_timesteps(sigmas=sigmas, device=device, **kwargs) + timesteps = scheduler.timesteps + num_inference_steps = len(timesteps) + else: + scheduler.set_timesteps(num_inference_steps, device=device, **kwargs) + timesteps = scheduler.timesteps + return timesteps, num_inference_steps + + +# Copied from diffusers.pipelines.stable_diffusion.pipeline_stable_diffusion_img2img.retrieve_latents +def retrieve_latents(encoder_output: torch.Tensor, generator: torch.Generator = None, sample_mode: str = "sample"): + if hasattr(encoder_output, "latent_dist") and sample_mode == "sample": + return encoder_output.latent_dist.sample(generator) + elif hasattr(encoder_output, "latent_dist") and sample_mode == "argmax": + return encoder_output.latent_dist.mode() + elif hasattr(encoder_output, "latents"): + return encoder_output.latents + else: + raise AttributeError("Could not access latents of provided encoder_output") + + +class Flux2Pipeline(nn.Module, SupportImageInput): + """Flux2 pipeline for text-to-image generation.""" + + _callback_tensor_inputs = ["latents", "prompt_embeds"] + + support_image_input = True + + def __init__( + self, + *, + od_config: OmniDiffusionConfig, + prefix: str = "", + ): + super().__init__() + self.od_config = od_config + self.weights_sources = [ + DiffusersPipelineLoader.ComponentSource( + model_or_path=od_config.model, + subfolder="transformer", + revision=None, + prefix="transformer.", + fall_back_to_pt=True, + ) + ] + + self._execution_device = get_local_device() + model = od_config.model + # Check if model is a local path + local_files_only = os.path.exists(model) + + self.scheduler = FlowMatchEulerDiscreteScheduler.from_pretrained( + model, subfolder="scheduler", local_files_only=local_files_only + ) + self.text_encoder = Mistral3ForConditionalGeneration.from_pretrained( + model, subfolder="text_encoder", local_files_only=local_files_only + ) + self.tokenizer = PixtralProcessor.from_pretrained( + model, subfolder="tokenizer", local_files_only=local_files_only + ) + self.vae = AutoencoderKLFlux2.from_pretrained(model, subfolder="vae", local_files_only=local_files_only).to( + self._execution_device + ) + transformer_kwargs = get_transformer_config_kwargs(od_config.tf_model_config, Flux2Transformer2DModel) + self.transformer = Flux2Transformer2DModel(**transformer_kwargs) + + self.vae_scale_factor = 2 ** (len(self.vae.config.block_out_channels) - 1) if getattr(self, "vae", None) else 8 + self.image_processor = Flux2ImageProcessor(vae_scale_factor=self.vae_scale_factor * 2) + self.tokenizer_max_length = 512 + self.default_sample_size = 128 + + self.system_message = SYSTEM_MESSAGE + self.system_message_upsampling_t2i = SYSTEM_MESSAGE_UPSAMPLING_T2I + self.system_message_upsampling_i2i = SYSTEM_MESSAGE_UPSAMPLING_I2I + self.upsampling_max_image_size = UPSAMPLING_MAX_IMAGE_SIZE + + self._guidance_scale = None + self._attention_kwargs = None + self._num_timesteps = None + self._current_timestep = None + self._interrupt = False + + @staticmethod + def _get_mistral_3_small_prompt_embeds( + text_encoder: Mistral3ForConditionalGeneration, + tokenizer: AutoProcessor, + prompt: str | list[str], + dtype: torch.dtype | None = None, + device: torch.device | None = None, + max_sequence_length: int = 512, + system_message: str = SYSTEM_MESSAGE, + hidden_states_layers: list[int] = (10, 20, 30), + ): + dtype = text_encoder.dtype if dtype is None else dtype + device = text_encoder.device if device is None else device + + prompt = [prompt] if isinstance(prompt, str) else prompt + + # Format input messages + messages_batch = format_input(prompts=prompt, system_message=system_message) + + # Process all messages at once + inputs = tokenizer.apply_chat_template( + messages_batch, + add_generation_prompt=False, + tokenize=True, + return_dict=True, + return_tensors="pt", + padding="max_length", + truncation=True, + max_length=max_sequence_length, + ) + + # Move to device + input_ids = inputs["input_ids"].to(device) + attention_mask = inputs["attention_mask"].to(device) + + # Forward pass through the model + output = text_encoder( + input_ids=input_ids, + attention_mask=attention_mask, + output_hidden_states=True, + use_cache=False, + ) + + # Only use outputs from intermediate layers and stack them + out = torch.stack([output.hidden_states[k] for k in hidden_states_layers], dim=1) + out = out.to(dtype=dtype, device=device) + + batch_size, num_channels, seq_len, hidden_dim = out.shape + prompt_embeds = out.permute(0, 2, 1, 3).reshape(batch_size, seq_len, num_channels * hidden_dim) + + return prompt_embeds + + @staticmethod + # Copied from diffusers.pipelines.flux2.pipeline_flux2.Flux2Pipeline._prepare_text_ids + def _prepare_text_ids( + x: torch.Tensor, # (B, L, D) or (L, D) + t_coord: torch.Tensor | None = None, + ): + B, L, _ = x.shape + out_ids = [] + + for i in range(B): + t = torch.arange(1) if t_coord is None else t_coord[i] + h = torch.arange(1) + w = torch.arange(1) + seq_positions = torch.arange(L) + + coords = torch.cartesian_prod(t, h, w, seq_positions) + out_ids.append(coords) + + return torch.stack(out_ids) + + @staticmethod + # Copied from diffusers.pipelines.flux2.pipeline_flux2.Flux2Pipeline._prepare_latent_ids + def _prepare_latent_ids( + latents: torch.Tensor, # (B, C, H, W) + ): + r""" + Generates 4D position coordinates (T, H, W, L) for latent tensors. + + Args: + latents (torch.Tensor): + Latent tensor of shape (B, C, H, W) + + Returns: + torch.Tensor: + Position IDs tensor of shape (B, H*W, 4) All batches share the same coordinate structure: T=0, + H=[0..H-1], W=[0..W-1], L=0 + """ + + batch_size, _, height, width = latents.shape + + t = torch.arange(1) # [0] - time dimension + h = torch.arange(height) + w = torch.arange(width) + layer_ids = torch.arange(1) # [0] - layer dimension + + # Create position IDs: (H*W, 4) + latent_ids = torch.cartesian_prod(t, h, w, layer_ids) + + # Expand to batch: (B, H*W, 4) + latent_ids = latent_ids.unsqueeze(0).expand(batch_size, -1, -1) + + return latent_ids + + @staticmethod + # Copied from diffusers.pipelines.flux2.pipeline_flux2.Flux2Pipeline._prepare_image_ids + def _prepare_image_ids( + image_latents: list[torch.Tensor], # [(1, C, H, W), (1, C, H, W), ...] + scale: int = 10, + ): + r""" + Generates 4D time-space coordinates (T, H, W, L) for a sequence of image latents. + + This function creates a unique coordinate for every pixel/patch across all input latent with different + dimensions. + + Args: + image_latents (List[torch.Tensor]): + A list of image latent feature tensors, typically of shape (C, H, W). + scale (int, optional): + A factor used to define the time separation (T-coordinate) between latents. T-coordinate for the i-th + latent is: 'scale + scale * i'. Defaults to 10. + + Returns: + torch.Tensor: + The combined coordinate tensor. Shape: (1, N_total, 4) Where N_total is the sum of (H * W) for all + input latents. + + Coordinate Components (Dimension 4): + - T (Time): The unique index indicating which latent image the coordinate belongs to. + - H (Height): The row index within that latent image. + - W (Width): The column index within that latent image. + - L (Seq. Length): A sequence length dimension, which is always fixed at 0 (size 1) + """ + + if not isinstance(image_latents, list): + raise ValueError(f"Expected `image_latents` to be a list, got {type(image_latents)}.") + + # create time offset for each reference image + t_coords = [scale + scale * t for t in torch.arange(0, len(image_latents))] + t_coords = [t.view(-1) for t in t_coords] + + image_latent_ids = [] + for x, t in zip(image_latents, t_coords): + x = x.squeeze(0) + _, height, width = x.shape + + x_ids = torch.cartesian_prod(t, torch.arange(height), torch.arange(width), torch.arange(1)) + image_latent_ids.append(x_ids) + + image_latent_ids = torch.cat(image_latent_ids, dim=0) + image_latent_ids = image_latent_ids.unsqueeze(0) + + return image_latent_ids + + @staticmethod + # Copied from diffusers.pipelines.flux2.pipeline_flux2.Flux2Pipeline._patchify_latents + def _patchify_latents(latents): + batch_size, num_channels_latents, height, width = latents.shape + latents = latents.view(batch_size, num_channels_latents, height // 2, 2, width // 2, 2) + latents = latents.permute(0, 1, 3, 5, 2, 4) + latents = latents.reshape(batch_size, num_channels_latents * 4, height // 2, width // 2) + return latents + + @staticmethod + # Copied from diffusers.pipelines.flux2.pipeline_flux2.Flux2Pipeline._unpatchify_latents + def _unpatchify_latents(latents): + batch_size, num_channels_latents, height, width = latents.shape + latents = latents.reshape(batch_size, num_channels_latents // (2 * 2), 2, 2, height, width) + latents = latents.permute(0, 1, 4, 2, 5, 3) + latents = latents.reshape(batch_size, num_channels_latents // (2 * 2), height * 2, width * 2) + return latents + + @staticmethod + # Copied from diffusers.pipelines.flux2.pipeline_flux2.Flux2Pipeline._pack_latents + def _pack_latents(latents): + """ + pack latents: (batch_size, num_channels, height, width) -> (batch_size, height * width, num_channels) + """ + + batch_size, num_channels, height, width = latents.shape + latents = latents.reshape(batch_size, num_channels, height * width).permute(0, 2, 1) + + return latents + + @staticmethod + # Copied from diffusers.pipelines.flux2.pipeline_flux2.Flux2Pipeline._unpack_latents_with_ids + def _unpack_latents_with_ids(x: torch.Tensor, x_ids: torch.Tensor) -> list[torch.Tensor]: + """ + using position ids to scatter tokens into place + """ + x_list = [] + for data, pos in zip(x, x_ids): + _, ch = data.shape # noqa: F841 + h_ids = pos[:, 1].to(torch.int64) + w_ids = pos[:, 2].to(torch.int64) + + h = torch.max(h_ids) + 1 + w = torch.max(w_ids) + 1 + + flat_ids = h_ids * w + w_ids + + out = torch.zeros((h * w, ch), device=data.device, dtype=data.dtype) + out.scatter_(0, flat_ids.unsqueeze(1).expand(-1, ch), data) + + # reshape from (H * W, C) to (H, W, C) and permute to (C, H, W) + + out = out.view(h, w, ch).permute(2, 0, 1) + x_list.append(out) + + return torch.stack(x_list, dim=0) + + # Copied from diffusers.pipelines.flux2.pipeline_flux2.Flux2Pipeline.upsample_prompt + def upsample_prompt( + self, + prompt: str | list[str], + images: list[PIL.Image.Image] | list[list[PIL.Image.Image]] = None, + temperature: float = 0.15, + device: torch.device = None, + ) -> list[str]: + prompt = [prompt] if isinstance(prompt, str) else prompt + device = self.text_encoder.device if device is None else device + + # Set system message based on whether images are provided + if images is None or len(images) == 0 or images[0] is None: + system_message = SYSTEM_MESSAGE_UPSAMPLING_T2I + else: + system_message = SYSTEM_MESSAGE_UPSAMPLING_I2I + + # Validate and process the input images + if images: + images = _validate_and_process_images(images, self.image_processor, self.upsampling_max_image_size) + + # Format input messages + messages_batch = format_input(prompts=prompt, system_message=system_message, images=images) + + # Process all messages at once + # with image processing a too short max length can throw an error in here. + inputs = self.tokenizer.apply_chat_template( + messages_batch, + add_generation_prompt=True, + tokenize=True, + return_dict=True, + return_tensors="pt", + padding="max_length", + truncation=True, + max_length=2048, + ) + + # Move to device + inputs["input_ids"] = inputs["input_ids"].to(device) + inputs["attention_mask"] = inputs["attention_mask"].to(device) + + if "pixel_values" in inputs: + inputs["pixel_values"] = inputs["pixel_values"].to(device, self.text_encoder.dtype) + + # Generate text using the model's generate method + generated_ids = self.text_encoder.generate( + **inputs, + max_new_tokens=512, + do_sample=True, + temperature=temperature, + use_cache=True, + ) + + # Decode only the newly generated tokens (skip input tokens) + # Extract only the generated portion + input_length = inputs["input_ids"].shape[1] + generated_tokens = generated_ids[:, input_length:] + + upsampled_prompt = self.tokenizer.tokenizer.batch_decode( + generated_tokens, skip_special_tokens=True, clean_up_tokenization_spaces=True + ) + return upsampled_prompt + + def encode_prompt( + self, + prompt: str | list[str], + device: torch.device | None = None, + num_images_per_prompt: int = 1, + prompt_embeds: torch.Tensor | None = None, + max_sequence_length: int = 512, + text_encoder_out_layers: tuple[int, ...] = (10, 20, 30), + ): + device = device or self._execution_device + + if prompt is None: + prompt = "" + + prompt = [prompt] if isinstance(prompt, str) else prompt + + if prompt_embeds is None: + prompt_embeds = self._get_mistral_3_small_prompt_embeds( + text_encoder=self.text_encoder, + tokenizer=self.tokenizer, + prompt=prompt, + device=device, + max_sequence_length=max_sequence_length, + system_message=self.system_message, + hidden_states_layers=text_encoder_out_layers, + ) + + batch_size, seq_len, _ = prompt_embeds.shape + prompt_embeds = prompt_embeds.repeat(1, num_images_per_prompt, 1) + prompt_embeds = prompt_embeds.view(batch_size * num_images_per_prompt, seq_len, -1) + + text_ids = self._prepare_text_ids(prompt_embeds) + text_ids = text_ids.to(device) + return prompt_embeds, text_ids + + # Copied from diffusers.pipelines.flux2.pipeline_flux2.Flux2Pipeline._encode_vae_image + def _encode_vae_image(self, image: torch.Tensor, generator: torch.Generator): + if image.ndim != 4: + raise ValueError(f"Expected image dims 4, got {image.ndim}.") + + image_latents = retrieve_latents(self.vae.encode(image), generator=generator, sample_mode="argmax") + image_latents = self._patchify_latents(image_latents) + + latents_bn_mean = self.vae.bn.running_mean.view(1, -1, 1, 1).to(image_latents.device, image_latents.dtype) + latents_bn_std = torch.sqrt(self.vae.bn.running_var.view(1, -1, 1, 1) + self.vae.config.batch_norm_eps) + image_latents = (image_latents - latents_bn_mean) / latents_bn_std + + return image_latents + + # Copied from diffusers.pipelines.flux2.pipeline_flux2.Flux2Pipeline.prepare_latents + def prepare_latents( + self, + batch_size, + num_latents_channels, + height, + width, + dtype, + device, + generator: torch.Generator, + latents: torch.Tensor | None = None, + ): + # VAE applies 8x compression on images but we must also account for packing which requires + # latent height and width to be divisible by 2. + height = 2 * (int(height) // (self.vae_scale_factor * 2)) + width = 2 * (int(width) // (self.vae_scale_factor * 2)) + + shape = (batch_size, num_latents_channels * 4, height // 2, width // 2) + if isinstance(generator, list) and len(generator) != batch_size: + raise ValueError( + f"You have passed a list of generators of length {len(generator)}, but requested an effective batch" + f" size of {batch_size}. Make sure the batch size matches the length of the generators." + ) + if latents is None: + latents = randn_tensor(shape, generator=generator, device=device, dtype=dtype) + else: + latents = latents.to(device=device, dtype=dtype) + + latent_ids = self._prepare_latent_ids(latents) + latent_ids = latent_ids.to(device) + + latents = self._pack_latents(latents) # [B, C, H, W] -> [B, H*W, C] + return latents, latent_ids + + # Copied from diffusers.pipelines.flux2.pipeline_flux2.Flux2Pipeline.prepare_image_latents + def prepare_image_latents( + self, + images: list[torch.Tensor], + batch_size, + generator: torch.Generator, + device, + dtype, + ): + image_latents = [] + for image in images: + image = image.to(device=device, dtype=dtype) + image_latent = self._encode_vae_image(image=image, generator=generator) + image_latents.append(image_latent) # (1, 128, 32, 32) + + image_latent_ids = self._prepare_image_ids(image_latents) + + # Pack each latent and concatenate + packed_latents = [] + for latent in image_latents: + # latent: (1, 128, 32, 32) + packed = self._pack_latents(latent) # (1, 1024, 128) + packed = packed.squeeze(0) # (1024, 128) - remove batch dim + packed_latents.append(packed) + + # Concatenate all reference tokens along sequence dimension + image_latents = torch.cat(packed_latents, dim=0) # (N*1024, 128) + image_latents = image_latents.unsqueeze(0) # (1, N*1024, 128) + + image_latents = image_latents.repeat(batch_size, 1, 1) + image_latent_ids = image_latent_ids.repeat(batch_size, 1, 1) + image_latent_ids = image_latent_ids.to(device) + + return image_latents, image_latent_ids + + def check_inputs( + self, + prompt, + height, + width, + prompt_embeds=None, + callback_on_step_end_tensor_inputs=None, + ): + if ( + height is not None + and height % (self.vae_scale_factor * 2) != 0 + or width is not None + and width % (self.vae_scale_factor * 2) != 0 + ): + logger.warning( + "`height` and `width` have to be divisible by %s but are %s and %s. " + "Dimensions will be resized accordingly", + self.vae_scale_factor * 2, + height, + width, + ) + + if callback_on_step_end_tensor_inputs is not None and not all( + k in self._callback_tensor_inputs for k in callback_on_step_end_tensor_inputs + ): + raise ValueError( + f"`callback_on_step_end_tensor_inputs` has to be in {self._callback_tensor_inputs}, " + f"but found {[k for k in callback_on_step_end_tensor_inputs if k not in self._callback_tensor_inputs]}" + ) + + if prompt is not None and prompt_embeds is not None: + raise ValueError( + f"Cannot forward both `prompt`: {prompt} and `prompt_embeds`: {prompt_embeds}. Please make sure to" + " only forward one of the two." + ) + elif prompt is None and prompt_embeds is None: + raise ValueError( + "Provide either `prompt` or `prompt_embeds`. Cannot leave both `prompt` and `prompt_embeds` undefined." + ) + elif prompt is not None and (not isinstance(prompt, str) and not isinstance(prompt, list)): + raise ValueError(f"`prompt` has to be of type `str` or `list` but is {type(prompt)}") + + @property + def guidance_scale(self): + return self._guidance_scale + + @property + def attention_kwargs(self): + return self._attention_kwargs + + @property + def num_timesteps(self): + return self._num_timesteps + + @property + def current_timestep(self): + return self._current_timestep + + @property + def interrupt(self): + return self._interrupt + + def forward( + self, + req: OmniDiffusionRequest, + image: PIL.Image.Image | list[PIL.Image.Image] | None = None, + prompt: str | list[str] | None = None, + height: int | None = None, + width: int | None = None, + num_inference_steps: int = 50, + sigmas: list[float] | None = None, + guidance_scale: float | None = 4.0, + num_images_per_prompt: int = 1, + generator: torch.Generator | list[torch.Generator] | None = None, + latents: torch.Tensor | None = None, + prompt_embeds: torch.Tensor | None = None, + negative_prompt_embeds: torch.Tensor | None = None, + output_type: str | None = "pil", + return_dict: bool = True, + attention_kwargs: dict[str, Any] | None = None, + callback_on_step_end: Callable[[int, int, dict], None] | None = None, + callback_on_step_end_tensor_inputs: list[str] = ["latents"], + max_sequence_length: int = 512, + text_encoder_out_layers: tuple[int, ...] = (10, 20, 30), + caption_upsample_temperature: float = None, + ) -> DiffusionOutput: + if len(req.prompts) > 1: + logger.warning( + """This model only supports a single prompt, not a batched request.""", + """Taking only the first image for now.""", + ) + first_prompt = req.prompts[0] + prompt = first_prompt if isinstance(first_prompt, str) else (first_prompt.get("prompt") or "") + + if ( + raw_image := None + if isinstance(first_prompt, str) + else first_prompt.get("multi_modal_data", {}).get("image") + ) is None: + pass # use image from param list + elif isinstance(raw_image, list): + image = [PIL.Image.open(im) if isinstance(im, str) else cast(PIL.Image.Image, im) for im in raw_image] + else: + image = PIL.Image.open(raw_image) if isinstance(raw_image, str) else cast(PIL.Image.Image, raw_image) + + height = req.sampling_params.height or height + width = req.sampling_params.width or width + num_inference_steps = req.sampling_params.num_inference_steps or num_inference_steps + sigmas = req.sampling_params.sigmas or sigmas + guidance_scale = ( + req.sampling_params.guidance_scale if req.sampling_params.guidance_scale is not None else guidance_scale + ) + generator = req.sampling_params.generator or generator + num_images_per_prompt = ( + req.sampling_params.num_outputs_per_prompt + if req.sampling_params.num_outputs_per_prompt > 0 + else num_images_per_prompt + ) + max_sequence_length = req.sampling_params.max_sequence_length or max_sequence_length + text_encoder_out_layers = req.sampling_params.extra_args.get("text_encoder_out_layers", text_encoder_out_layers) + + req_prompt_embeds = [p.get("prompt_embeds") if not isinstance(p, str) else None for p in req.prompts] + if any(p is not None for p in req_prompt_embeds): + # If at list one prompt is provided as an embedding, + # Then assume that the user wants to provide embeddings for all prompts, and enter this if block + # If the user in fact provides mixed input format, req_prompt_embeds will have some None's + # And `torch.stack` automatically raises an exception for us + prompt_embeds = torch.stack(req_prompt_embeds) # type: ignore # intentionally expect TypeError + + # 1. Check inputs. Raise error if not correct + self.check_inputs( + prompt=prompt, + height=height, + width=width, + prompt_embeds=prompt_embeds, + callback_on_step_end_tensor_inputs=callback_on_step_end_tensor_inputs, + ) + + self._guidance_scale = guidance_scale + self._attention_kwargs = attention_kwargs + self._current_timestep = None + self._interrupt = False + + # 2. Define call parameters + if prompt is not None and isinstance(prompt, str): + batch_size = 1 + elif prompt is not None and isinstance(prompt, list): + batch_size = len(prompt) + else: + batch_size = prompt_embeds.shape[0] + + device = self._execution_device + + # 3. prepare text embeddings + if caption_upsample_temperature: + prompt = self.upsample_prompt(prompt, images=image, temperature=caption_upsample_temperature, device=device) + prompt_embeds, text_ids = self.encode_prompt( + prompt=prompt, + prompt_embeds=prompt_embeds, + device=device, + num_images_per_prompt=num_images_per_prompt, + max_sequence_length=max_sequence_length, + text_encoder_out_layers=text_encoder_out_layers, + ) + + # 4. process images + if image is not None and not isinstance(image, list): + image = [image] + + condition_images = None + if image is not None: + for img in image: + self.image_processor.check_image_input(img) + + condition_images = [] + for img in image: + image_width, image_height = img.size + if image_width * image_height > 1024 * 1024: + img = self.image_processor._resize_to_target_area(img, 1024 * 1024) + image_width, image_height = img.size + + multiple_of = self.vae_scale_factor * 2 + image_width = (image_width // multiple_of) * multiple_of + image_height = (image_height // multiple_of) * multiple_of + img = self.image_processor.preprocess(img, height=image_height, width=image_width, resize_mode="crop") + condition_images.append(img) + height = height or image_height + width = width or image_width + + height = height or self.default_sample_size * self.vae_scale_factor + width = width or self.default_sample_size * self.vae_scale_factor + + # 5. prepare latent variables + num_channels_latents = self.transformer.config.in_channels // 4 + latents, latent_ids = self.prepare_latents( + batch_size=batch_size * num_images_per_prompt, + num_latents_channels=num_channels_latents, + height=height, + width=width, + dtype=prompt_embeds.dtype, + device=device, + generator=generator, + latents=latents, + ) + + image_latents = None + image_latent_ids = None + if condition_images is not None: + image_latents, image_latent_ids = self.prepare_image_latents( + images=condition_images, + batch_size=batch_size * num_images_per_prompt, + generator=generator, + device=device, + dtype=self.vae.dtype, + ) + + # 6. Prepare timesteps + sigmas = np.linspace(1.0, 1 / num_inference_steps, num_inference_steps) if sigmas is None else sigmas + if hasattr(self.scheduler.config, "use_flow_sigmas") and self.scheduler.config.use_flow_sigmas: + sigmas = None + image_seq_len = latents.shape[1] + mu = compute_empirical_mu(image_seq_len=image_seq_len, num_steps=num_inference_steps) + timesteps, num_inference_steps = retrieve_timesteps( + self.scheduler, + num_inference_steps, + device, + sigmas=sigmas, + mu=mu, + ) + self._num_timesteps = len(timesteps) + + # 7. Denoising loop + # We set the index here to remove DtoH sync, helpful especially during compilation. + # Check out more details here: https://github.com/huggingface/diffusers/pull/11696 + self.scheduler.set_begin_index(0) + for i, t in enumerate(timesteps): + if self.interrupt: + continue + + self._current_timestep = t + timestep = t.expand(latents.shape[0]).to(latents.dtype) + + latent_model_input = latents.to(self.transformer.dtype) + latent_image_ids = latent_ids + + if image_latents is not None: + latent_model_input = torch.cat([latents, image_latents], dim=1).to(self.transformer.dtype) + latent_image_ids = torch.cat([latent_ids, image_latent_ids], dim=1) + + noise_pred = self.transformer( + hidden_states=latent_model_input, # (B, image_seq_len, C) + timestep=timestep / 1000, + guidance=None, + encoder_hidden_states=prompt_embeds, + txt_ids=text_ids, # B, text_seq_len, 4 + img_ids=latent_image_ids, # B, image_seq_len, 4 + joint_attention_kwargs=self.attention_kwargs, + return_dict=False, + )[0] + + noise_pred = noise_pred[:, : latents.size(1) :] + + # compute the previous noisy sample x_t -> x_t-1 + latents_dtype = latents.dtype + latents = self.scheduler.step(noise_pred, t, latents, return_dict=False)[0] + + if latents.dtype != latents_dtype and torch.backends.mps.is_available(): + latents = latents.to(latents_dtype) + + if callback_on_step_end is not None: + callback_kwargs = {} + for k in callback_on_step_end_tensor_inputs: + callback_kwargs[k] = locals()[k] + callback_outputs = callback_on_step_end(self, i, t, callback_kwargs) + + latents = callback_outputs.pop("latents", latents) + prompt_embeds = callback_outputs.pop("prompt_embeds", prompt_embeds) + + self._current_timestep = None + + latents = self._unpack_latents_with_ids(latents, latent_ids) + + latents_bn_mean = self.vae.bn.running_mean.view(1, -1, 1, 1).to(latents.device, latents.dtype) + latents_bn_std = torch.sqrt(self.vae.bn.running_var.view(1, -1, 1, 1) + self.vae.config.batch_norm_eps).to( + latents.device, latents.dtype + ) + latents = latents * latents_bn_std + latents_bn_mean + latents = self._unpatchify_latents(latents) + if output_type == "latent": + image = latents + else: + if latents.dtype != self.vae.dtype: + latents = latents.to(self.vae.dtype) + image = self.vae.decode(latents, return_dict=False)[0] + + return DiffusionOutput(output=image) + + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: + loader = AutoWeightsLoader(self) + return loader.load_weights(weights) diff --git a/vllm_omni/diffusion/registry.py b/vllm_omni/diffusion/registry.py index 1b48c9c7d..d5af2de93 100644 --- a/vllm_omni/diffusion/registry.py +++ b/vllm_omni/diffusion/registry.py @@ -132,6 +132,11 @@ _DIFFUSION_MODELS = { "pipeline_helios", "HeliosPipeline", ), + "Flux2Pipeline": ( + "flux2", + "pipeline_flux2", + "Flux2Pipeline", + ), } @@ -305,6 +310,7 @@ _DIFFUSION_POST_PROCESS_FUNCS = { "OmniGen2Pipeline": "get_omnigen2_post_process_func", "HeliosPipeline": "get_helios_post_process_func", "HeliosPyramidPipeline": "get_helios_post_process_func", + "Flux2Pipeline": "get_flux2_post_process_func", } _DIFFUSION_PRE_PROCESS_FUNCS = { From b313279af757c3b202f01179219221069709bc85 Mon Sep 17 00:00:00 2001 From: Samit <285365963@qq.com> Date: Wed, 11 Mar 2026 17:32:12 +0800 Subject: [PATCH 05/38] [skip ci][Docs] doc fix for example snippets (#1811) Signed-off-by: samithuang <285365963@qq.com> Signed-off-by: Samit <285365963@qq.com> --- docs/mkdocs/hooks/generate_examples.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/mkdocs/hooks/generate_examples.py b/docs/mkdocs/hooks/generate_examples.py index 4e840280b..b4c928936 100644 --- a/docs/mkdocs/hooks/generate_examples.py +++ b/docs/mkdocs/hooks/generate_examples.py @@ -132,9 +132,19 @@ class Example: ".dat", ".db", ".sqlite", # Data files + ".pyc", + ".pyo", + ".pyd", # Python compiled + ".npy", + ".npz", + ".pkl", + ".pickle", # Serialized data } + excluded_dirs = {"__pycache__", ".git", "node_modules", ".tox", ".mypy_cache"} def is_other_file(file: Path) -> bool: + if any(part in excluded_dirs for part in file.parts): + return False return file.is_file() and file != self.main_file and file.suffix.lower() not in binary_extensions return [file for file in self.path.rglob("*") if is_other_file(file)] From a19098233b62a6381ebb771c6dbf5813ad436264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zeyu=20Huang=20=7C=20=E9=BB=83=E6=BE=A4=E5=AE=87?= <11222265+fhfuih@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:10:06 +0800 Subject: [PATCH 06/38] [Test] L4 complete diffusion feature test for Qwen-Image-Edit models (#1682) Signed-off-by: Huang, Zeyu <11222265+fhfuih@users.noreply.github.com> Co-authored-by: Hongsheng Liu --- .buildkite/test-nightly.yml | 37 ++- docs/contributing/ci/CI_5levels.md | 6 +- docs/contributing/ci/tests_markers.md | 30 +- docs/contributing/ci/tests_style.md | 1 + .../model/adding_diffusion_model.md | 23 +- tests/benchmarks/test_serve_cli.py | 5 +- tests/conftest.py | 263 ++++++++++++++++-- tests/e2e/online_serving/test_qwen3_omni.py | 9 +- .../test_qwen3_omni_expansion.py | 29 +- .../test_qwen_image_edit_expansion.py | 164 +++++++++++ .../online_serving/test_qwen2_5_omni.py | 8 +- .../online_serving/test_qwen3_omni.py | 8 +- tests/utils.py | 52 ++-- 13 files changed, 564 insertions(+), 71 deletions(-) create mode 100644 tests/e2e/online_serving/test_qwen_image_edit_expansion.py diff --git a/.buildkite/test-nightly.yml b/.buildkite/test-nightly.yml index 001c46645..013640988 100644 --- a/.buildkite/test-nightly.yml +++ b/.buildkite/test-nightly.yml @@ -54,7 +54,6 @@ steps: - docker#v5.2.0: image: public.ecr.aws/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT always-pull: true - shm-size: "8gb" propagate-environment: true shm-size: "8gb" environment: @@ -62,6 +61,42 @@ steps: volumes: - "/fsx/hf_cache:/fsx/hf_cache" + - label: ":full_moon: Diffusion Model Test with H100" + timeout_in_minutes: 60 + depends_on: upload-nightly-pipeline + # if: build.env("NIGHTLY") == "1" + commands: + - export VLLM_WORKER_MULTIPROC_METHOD=spawn + - pytest -s -v tests/e2e/online_serving/test_*_expansion.py -m "advanced_model and diffusion and H100" --run-level "advanced_model" + agents: + queue: "mithril-h100-pool" + plugins: + - kubernetes: + podSpec: + containers: + - image: 936637512419.dkr.ecr.us-west-2.amazonaws.com/vllm-ci-pull-through-cache/q9t5s3a7/vllm-ci-test-repo:$BUILDKITE_COMMIT + resources: + limits: + nvidia.com/gpu: 2 + volumeMounts: + - name: devshm + mountPath: /dev/shm + - name: hf-cache + mountPath: /root/.cache/huggingface + env: + - name: HF_HOME + value: /root/.cache/huggingface + nodeSelector: + node.kubernetes.io/instance-type: gpu-h100-sxm + volumes: + - name: devshm + emptyDir: + medium: Memory + - name: hf-cache + hostPath: + path: /mnt/hf-cache + type: DirectoryOrCreate + - label: ":full_moon: Qwen3-TTS Non-Async-Chunk E2E Test" timeout_in_minutes: 30 depends_on: upload-nightly-pipeline diff --git a/docs/contributing/ci/CI_5levels.md b/docs/contributing/ci/CI_5levels.md index c8493af0a..54af850bb 100644 --- a/docs/contributing/ci/CI_5levels.md +++ b/docs/contributing/ci/CI_5levels.md @@ -508,7 +508,9 @@ L3 level testing executes after code is merged into the main branch. Its core pu **2.4.5 Request Execution** ```python - openai_client.send_request(request_config, request_num=1) + openai_client.send_omni_request(request_config, request_num=1) # for omni-understanding models + # or + openai_client.send_diffusion_request(request_config, request_num=1) # for diffusion models ``` **Explanation**: @@ -517,7 +519,7 @@ L3 level testing executes after code is merged into the main branch. Its core pu **Single Request**: The comment clearly states this is a single-request completion test. For concurrent testing, it can be extended to multiple requests using request_num = n. - **Implicit Validation**: The send_request method internally includes validation logic dynamically selected based on the --run-level parameter: core_model performs basic validation, while advanced_model performs deep validation. + **Implicit Validation**: The `send_omni_request` and `send_diffusion_request` methods internally includes validation logic dynamically selected based on the --run-level parameter: core_model performs basic validation, while advanced_model performs deep validation. - ***Run Command***: `pytest -s -v /tests/e2e/online_serving/test_{model_name}.py -m advanced_model --run-level=advanced_model` diff --git a/docs/contributing/ci/tests_markers.md b/docs/contributing/ci/tests_markers.md index c9e5d18e6..6200ca5ae 100644 --- a/docs/contributing/ci/tests_markers.md +++ b/docs/contributing/ci/tests_markers.md @@ -32,7 +32,7 @@ Defined in `pyproject.toml`: | `slow` | Slow tests (may skip in quick CI) | | `benchmark` | Benchmark tests | -\* Means those markers are auto-added, and they will be added by the `@hardware_test` decorator. +\* Means those markers are auto-added by `@hardware_test` (parametrization decorator) or `hardware_marks` (only returning the list of marks for flexibility). ### Example usage for markers @@ -49,6 +49,7 @@ from tests.utils import hardware_test def test_video_to_audio() ... ``` + ### Decorator: `@hardware_test` This decorator is intended to make hardware-aware, cross-platform test authoring easier and more robust for CI/CD environments. The `hardware_test` decorator in `vllm-omni/tests/utils.py` performs the following actions: @@ -98,6 +99,24 @@ This decorator is intended to make hardware-aware, cross-platform test authoring - ROCm only: `pytest -m "distributed_rocm and MI325"` - NPU only: `pytest -m "distributed_npu"` +### Function: `hardware_marks` + +`hardware_marks` returns a list of pytest mark objects with the same signature as `@hardware_test`. Use it when you need more flexibility, such as attaching hardware marks to individual `pytest.param` entries rather than an entire test function. + +```python +from tests.utils import hardware_marks + +MULTI_CARD_MARKS = hardware_marks( + res={"cuda": "H100", "rocm": "MI325", "npu": "A2"}, num_cards=2 +) + +@pytest.mark.parametrize("omni_server", [ + pytest.param(OmniServerParams(...), id="case_001", marks=MULTI_CARD_MARKS), +], indirect=True) +def test_feature(omni_server): + ... +``` + ## Add Support for a New Platform If you want to add support for a new platform (e.g., "tpu" for a new accelerator), follow these steps: @@ -132,12 +151,13 @@ If you want to add support for a new platform (e.g., "tpu" for a new accelerator # Optionally: add skipif_tpu when implemented return [test_platform, test_resource, test_distributed] ``` -3. **Update `hardware_test` to recognize your new platform**: - In the relevant place (see the `hardware_test` implementation), add: +3. **Update `hardware_marks` to recognize your new platform**: + In the relevant place (see the `hardware_marks` implementation), add: ```python if platform == "tpu": marks = tpu_marks(res=resource, num_cards=cards) ``` + (`hardware_test` calls `hardware_marks` internally, so both will pick up the change.) 4. **(Recommended) Add a test using your new markers**: ```python @hardware_test( @@ -151,7 +171,7 @@ If you want to add support for a new platform (e.g., "tpu" for a new accelerator **Summary**: - Add pytest markers for your new platform/resources - Implement a marker function (`xxx_marks`) -- Plug into `hardware_test` -- You're done: tests decorated with `@hardware_test` using your platform now automatically get the correct markers, distribution, and isolation! +- Plug into `hardware_marks` +- You're done: tests using `@hardware_test` or `hardware_marks` with your platform now automatically get the correct markers, distribution, and isolation! See code in `vllm-omni/tests/utils.py` for existing examples (`cuda_marks`, `rocm_marks`, `npu_marks`). diff --git a/docs/contributing/ci/tests_style.md b/docs/contributing/ci/tests_style.md index ab94f2f56..a24f71864 100644 --- a/docs/contributing/ci/tests_style.md +++ b/docs/contributing/ci/tests_style.md @@ -158,6 +158,7 @@ vllm_omni/ tests/ ├── test_diffusion_layerwise_offload.py ├── test_diffusion_lora.py ├── test_sequence_parallel.py + ├── test_qwen_image_edit_expansion.py └── stage_configs/ ├── qwen2_5_omni_ci.yaml ├── qwen3_omni_ci.yaml diff --git a/docs/contributing/model/adding_diffusion_model.md b/docs/contributing/model/adding_diffusion_model.md index c1094802c..818bd15b7 100644 --- a/docs/contributing/model/adding_diffusion_model.md +++ b/docs/contributing/model/adding_diffusion_model.md @@ -651,8 +651,27 @@ For a fair comparison, keep the same **prompt**, **seed**, **resolution**, **num #### 5.2 Functionality Check in CI -To ensure project maintainability and sustainable development, we encourage contributors to submit test code (unit tests, system tests, or end-to-end tests) alongside their code changes. -For comprehensive testing guidelines, please refer to the [Test File Structure and Style Guide](../ci/tests_style.md). +To ensure project maintainability and sustainable development, please submit test code (unit tests, system tests, or end-to-end tests) alongside their code changes. + +For comprehensive testing guidelines and the definition of test levels (L1-L5), please refer to the [Test File Structure and Style Guide](../ci/tests_style.md). +The following tests are required to add: + +- L4 test of the model's full *functionality* (i.e., all the *diffusion features* that are supported by this model), including several [parallelism acceleration methods](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/parallelism_acceleration/), [CPU offloading](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/cpu_offload_diffusion/), [TeaCache](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/teacache/) and [Cache-DiT](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/cache_dit_acceleration/) cache backends, [quantization methods](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/diffusion/quantization/overview/). + - Test cases: Currently all the features are available in online serving mode. Hence, only need to add `tests/e2e/online_serving/test_{model}_expansion.py`. The following test cases shall cover all features: + - 1 GPU: TeaCache & GGUF (or fallback to FP8, or disable it) & Layer-wise CPU offloading (or fallback to Module-wise) + - 2 GPUs: Cache-DiT & FP8 (or fallback to GGUF, or disable it) & Ulysses = 2 + - 2 GPUs: Cache-DiT & GGUF (or fallback to FP8, or disable it) & Ring = 2 + - 2 GPUs: TeaCache & FP8 (or fallback to GGUF, or disable it) & CFG Parallel = 2 + - 2 GPUs: Cache-DiT & FP8 (or fallback to GGUF, or disable it) & Tensor Parallel = 2 & VAE Patch Parallel = 2 + - 2 GPUs: Cache-DiT & GGUF (or fallback to FP8, or disable it) & HSDP = 2 & VAE Patch Parallel = 2 + - Validation: test that the multimodal output files of your model have the correct shapes. + - Test marks: always add `advanced_model` and `diffusion`. Add `parallel` and GPU-related marks if needed. Ref: [Markers for Tests](https://docs.vllm.ai/projects/vllm-omni/en/latest/contributing/ci/tests_markers/) + - To maximize code reuse, you may refer to + - `tests/conftest.py` for `omni_server` and `openai_client` fixtures, `generate_synthetic_image` and `assert_XXX_valid` helper. + - `tests/utils.py` for `@hardware_test(...)` and `hardware_marks`. + - [Parametrizing tests (pytest doc)](https://docs.pytest.org/en/stable/example/parametrize.html) to reuse test function implementation for different cases. + - Doc: add a concise dostring for each test function. + - Reference L4 test implementation: [tests/e2e/online_serving/test_qwen_image_edit_expansion.py](https://github.com/vllm-project/vllm-omni/blob/main/tests/e2e/online_serving/test_qwen_image_edit_expansion.py). --- diff --git a/tests/benchmarks/test_serve_cli.py b/tests/benchmarks/test_serve_cli.py index a48731934..4fc0b172c 100644 --- a/tests/benchmarks/test_serve_cli.py +++ b/tests/benchmarks/test_serve_cli.py @@ -3,13 +3,16 @@ from pathlib import Path import pytest +from tests.conftest import OmniServerParams from tests.utils import hardware_test models = ["Qwen/Qwen2.5-Omni-7B"] stage_configs = [str(Path(__file__).parent.parent / "e2e" / "stage_configs" / "qwen2_5_omni_ci.yaml")] # Create parameter combinations for model and stage config -test_params = [(model, stage_config) for model in models for stage_config in stage_configs] +test_params = [ + OmniServerParams(model=model, stage_config_path=stage_config) for model in models for stage_config in stage_configs +] @pytest.mark.core_model diff --git a/tests/conftest.py b/tests/conftest.py index 619dae3b8..0a3c350d7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,16 +18,21 @@ import subprocess import sys import threading import time +from collections.abc import Generator from dataclasses import dataclass +from io import BytesIO from pathlib import Path -from typing import Any +from typing import Any, NamedTuple +import imageio.v3 as iio import numpy as np import psutil import pytest +import soundfile as sf import torch import yaml -from openai import OpenAI +from openai import OpenAI, omit +from PIL import Image from vllm import TextPrompt from vllm.distributed.parallel_state import cleanup_dist_env_and_memory from vllm.logger import init_logger @@ -44,6 +49,65 @@ PromptImageInput = list[Any] | Any | None PromptVideoInput = list[Any] | Any | None +class OmniServerParams(NamedTuple): + model: str + port: int | None = None + stage_config_path: str | None = None + server_args: list[str] | None = None + + +def assert_image_valid(image: Path | Image.Image, *, width: int | None = None, height: int | None = None): + """Assert the file is a loadable image with optional exact dimensions.""" + if isinstance(image, Path): + assert image.exists(), f"Image not found: {image}" + image = Image.open(image) + image.load() + assert image.width > 0 and image.height > 0 + if width is not None: + assert image.width == width, f"Expected width={width}, got {image.width} in {image.name}" + if height is not None: + assert image.height == height, f"Expected height={height}, got {image.height} in {image.name}" + return image + + +def assert_video_valid(frames: Path | np.ndarray, *, width: int, height: int, num_frames: int) -> None: + """Assert the MP4 has the expected resolution and exact frame count.""" + if isinstance(frames, Path): + assert frames.exists(), f"Video not found: {frames}" + frames = iio.imread(str(frames), plugin="pyav", index=None) + assert frames.shape[0] == num_frames, f"Expected {num_frames} frames, got {frames.shape[0]}" + assert frames.shape[1] == height, f"Expected height={height}, got {frames.shape[1]}" + assert frames.shape[2] == width, f"Expected width={width}, got {frames.shape[2]}" + + +def assert_audio_valid(path: Path, *, sample_rate: int, channels: int, duration_s: float) -> None: + """Assert the WAV has the expected sample rate, channel count, and duration.""" + + assert path.exists(), f"Audio not found: {path}" + info = sf.info(str(path)) + assert info.samplerate == sample_rate, f"Expected sample_rate={sample_rate}, got {info.samplerate}" + assert info.channels == channels, f"Expected {channels} channel(s), got {info.channels}" + expected_frames = int(duration_s * sample_rate) + assert info.frames == expected_frames, ( + f"Expected {expected_frames} frames ({duration_s}s @ {sample_rate} Hz), got {info.frames}" + ) + + +def decode_b64_image(b64: str): + img = Image.open(BytesIO(base64.b64decode(b64))) + img.load() + return img + + +@pytest.fixture(scope="session") +def model_prefix() -> str: + """Optional model-path prefix from MODEL_PREFIX env var. + Useful if models are downloaded to non-default local directories. + """ + prefix = os.environ.get("MODEL_PREFIX", "") + return f"{prefix.rstrip('/')}/" if prefix else "" + + @pytest.fixture(autouse=True) def default_vllm_config(): """Set a default VllmConfig for all tests. @@ -1151,7 +1215,9 @@ def pytest_addoption(parser): @pytest.fixture(scope="session") -def run_level(request): +def run_level(request) -> str: + """A command-line argument that specifies the level of tests to run in this session. + See https://docs.vllm.ai/projects/vllm-omni/en/latest/contributing/ci/CI_5levels/""" return request.config.getoption("--run-level") @@ -1159,15 +1225,17 @@ _omni_server_lock = threading.Lock() @pytest.fixture(scope="module") -def omni_server(request, run_level): +def omni_server(request: pytest.FixtureRequest, run_level: str, model_prefix: str) -> Generator[OmniServer, Any, None]: """Start vLLM-Omni server as a subprocess with actual model weights. Uses session scope so the server starts only once for the entire test session. Multi-stage initialization can take 10-20+ minutes. """ with _omni_server_lock: - *port, model, stage_config_path = request.param - port = port[0] if port else None - if run_level == "advanced_model": + params: OmniServerParams = request.param + model = model_prefix + params.model + port = params.port + stage_config_path = params.stage_config_path + if run_level == "advanced_model" and stage_config_path is not None: stage_config_path = modify_stage_config( stage_config_path, deletes={ @@ -1179,7 +1247,11 @@ def omni_server(request, run_level): }, ) - server_args = ["--stage-configs-path", stage_config_path, "--stage-init-timeout", "120"] + server_args = params.server_args or [] + server_args = ["--stage-init-timeout", "120", *server_args] + if stage_config_path is not None: + server_args += ["--stage-configs-path", stage_config_path] + with OmniServer(model, server_args, port=port) if port else OmniServer(model, server_args) as server: print("OmniServer started successfully") yield server @@ -1199,6 +1271,17 @@ class OmniResponse: error_message: str | None = None +@dataclass +class DiffusionResponse: + text_content: str | None = None + images: list[Image.Image] | None = None + audios: list[Any] | None = None + videos: list[Any] | None = None + e2e_latency: float | None = None + success: bool = False + error_message: str | None = None + + def assert_omni_response(response: OmniResponse, request_config: dict[str, Any], run_level): """ Validate response results. @@ -1247,6 +1330,51 @@ def assert_omni_response(response: OmniResponse, request_config: dict[str, Any], print(f"similarity is: {response.similarity}") +def assert_diffusion_response(response: DiffusionResponse, request_config: dict[str, Any], run_level: str = None): + """ + Validate diffusion response results. + + Args: + response: DiffusionResponse object. Any not-None content will be validated based on the request_config. + request_config: Request configuration dictionary containing parameters like model, messages, extra_body. + When validating a certain modality, the corresponding params in request_config['extra_body'] must present. + It will be used to check against the multimedia file in the response. + run_level: Test run level (e.g., "core_model", "advanced_model") + + Raises: + AssertionError: When the response does not meet validation criteria + KeyError: When the request_config does not contain necessary parameters for validation + """ + assert response.success, "The request failed." + + e2e_latency = response.e2e_latency + if e2e_latency is not None: + print(f"the avg e2e is: {e2e_latency}") + + extra_body = request_config.get("extra_body", {}) + + num_outputs_per_prompt = extra_body.get("num_outputs_per_prompt", 1) + + if response.images is not None: + assert len(response.images) > 0, "No images in response" + assert len(response.images) == num_outputs_per_prompt, ( + f"Expected {num_outputs_per_prompt} images, got {len(response.images)}" + ) + if run_level == "advanced_model": + expected_width = extra_body["width"] # intend to raise KeyError + expected_height = extra_body["height"] # intend to raise KeyError + for img in response.images: + assert_image_valid(img, width=expected_width, height=expected_height) + if response.videos is not None: + raise NotImplementedError( + "Video validation is not implemented yet" + ) # consider using assert_video_valid defined above + if response.audios is not None: + raise NotImplementedError( + "Audio validation is not implemented yet" + ) # consider using assert_audio_valid defined above + + class OpenAIClientHandler: """ OpenAI client handler class, encapsulating both streaming and non-streaming response processing logic. @@ -1269,7 +1397,7 @@ class OpenAIClientHandler: self.client = OpenAI(base_url=f"http://{host}:{port}/v1", api_key=api_key) self.run_level = run_level - def _process_stream_response(self, chat_completion) -> OmniResponse: + def _process_stream_omni_response(self, chat_completion) -> OmniResponse: """ Process streaming responses. @@ -1330,7 +1458,7 @@ class OpenAIClientHandler: return result - def _process_non_stream_response(self, chat_completion) -> OmniResponse: + def _process_non_stream_omni_response(self, chat_completion) -> OmniResponse: """ Process non-streaming responses. @@ -1384,7 +1512,46 @@ class OpenAIClientHandler: return result - def send_request(self, request_config: dict[str, Any], request_num: int = 1) -> list[OmniResponse]: + def _process_diffusion_response(self, chat_completion) -> DiffusionResponse: + """ + Process diffusion responses (image generation/editing). + + Args: + chat_completion: OpenAI response object + + Returns: + DiffusionResponse: Processed response object + """ + result = DiffusionResponse() + start_time = time.perf_counter() + + try: + images = [] + # [TODO] reading video and audio output from API response for later validation + + for choice in chat_completion.choices: + if hasattr(choice.message, "content") and choice.message.content is not None: + content = choice.message.content + if isinstance(content, list): + for item in content: + if hasattr(item, "image_url") and item.image_url is not None: + image_url = item.image_url.url + if image_url.startswith("data:image"): + b64_data = image_url.split(",", 1)[1] + img = decode_b64_image(b64_data) + images.append(img) + + result.e2e_latency = time.perf_counter() - start_time + result.images = images if images else None + result.success = True + + except Exception as e: + result.error_message = f"Diffusion response processing error: {str(e)}" + print(f"Error: {result.error_message}") + + return result + + def send_omni_request(self, request_config: dict[str, Any], request_num: int = 1) -> list[OmniResponse]: """ Send OpenAI requests. @@ -1410,9 +1577,9 @@ class OpenAIClientHandler: ) if stream: - response = self._process_stream_response(chat_completion) + response = self._process_stream_omni_response(chat_completion) else: - response = self._process_non_stream_response(chat_completion) + response = self._process_non_stream_omni_response(chat_completion) assert_omni_response(response, request_config, run_level=self.run_level) responses.append(response) @@ -1438,25 +1605,82 @@ class OpenAIClientHandler: chat_completion = future.result() if stream: - response = self._process_stream_response(chat_completion) + response = self._process_stream_omni_response(chat_completion) else: - response = self._process_non_stream_response(chat_completion) + response = self._process_non_stream_omni_response(chat_completion) assert_omni_response(response, request_config, run_level=self.run_level) responses.append(response) return responses + def send_diffusion_request(self, request_config: dict[str, Any], request_num: int = 1) -> list[OmniResponse]: + """ + Send OpenAI requests for diffusion models. + + Args: + request_config: Request configuration dictionary containing parameters like model, messages + request_num: Number of requests to send concurrently, defaults to 1 (single request) + Returns: + List[OmniResponse]: List of response objects + """ + responses = [] + stream = request_config.get("stream", False) + modalities = request_config.get("modalities", omit) # Most diffusion models don't require modalities param + extra_body = request_config.get("extra_body", None) + + if stream: + raise NotImplementedError("Streaming is not currently implemented for diffusion model e2e test") + + if request_num == 1: + # Send single request + chat_completion = self.client.chat.completions.create( + model=request_config.get("model"), + messages=request_config.get("messages"), + extra_body=extra_body, + modalities=modalities, + ) + + response = self._process_diffusion_response(chat_completion) + assert_diffusion_response(response, request_config, run_level=self.run_level) + responses.append(response) + + else: + # Send concurrent requests + with concurrent.futures.ThreadPoolExecutor(max_workers=request_num) as executor: + futures = [] + + # Submit all request tasks + for _ in range(request_num): + future = executor.submit( + self.client.chat.completions.create, + model=request_config.get("model"), + messages=request_config.get("messages"), + modalities=modalities, + extra_body=extra_body, + ) + futures.append(future) + + # Process completed tasks + for future in concurrent.futures.as_completed(futures): + chat_completion = future.result() + response = self._process_diffusion_response(chat_completion) + assert_diffusion_response(response, request_config, run_level=self.run_level) + responses.append(response) + + return responses + @pytest.fixture -def openai_client(omni_server, run_level): - """Create OpenAIClientHandler fixture""" +def openai_client(omni_server: OmniServer, run_level: str): + """Create OpenAIClientHandler fixture to facilitate communication with OmniServer + with encapsulated request sending, concurrent requests, response handling, and validation.""" return OpenAIClientHandler(host=omni_server.host, port=omni_server.port, api_key="EMPTY", run_level=run_level) class OmniRunner: """ - Test runner for Omni models. + Offline test runner for Omni models. """ def __init__( @@ -1727,9 +1951,10 @@ class OmniRunner: @pytest.fixture(scope="module") -def omni_runner(request): +def omni_runner(request, model_prefix): with _omni_server_lock: model, stage_config_path = request.param + model = model_prefix + model with OmniRunner(model, seed=42, stage_configs_path=stage_config_path, stage_init_timeout=300) as runner: print("OmniRunner started successfully") yield runner diff --git a/tests/e2e/online_serving/test_qwen3_omni.py b/tests/e2e/online_serving/test_qwen3_omni.py index fb9986592..09f528dc0 100644 --- a/tests/e2e/online_serving/test_qwen3_omni.py +++ b/tests/e2e/online_serving/test_qwen3_omni.py @@ -8,6 +8,7 @@ from pathlib import Path import pytest from tests.conftest import ( + OmniServerParams, dummy_messages_from_mix_data, generate_synthetic_audio, generate_synthetic_image, @@ -51,7 +52,9 @@ else: stage_configs = [get_chunk_config()] # Create parameter combinations for model and stage config -test_params = [(model, stage_config) for model in models for stage_config in stage_configs] +test_params = [ + OmniServerParams(model=model, stage_config_path=stage_config) for model in models for stage_config in stage_configs +] def get_system_prompt(): @@ -120,7 +123,7 @@ def test_mix_to_text_audio_001(omni_server, openai_client) -> None: } # Test single completion - openai_client.send_request(request_config) + openai_client.send_omni_request(request_config) @pytest.mark.advanced_model @@ -146,4 +149,4 @@ def test_text_to_text_001(omni_server, openai_client) -> None: "key_words": {"text": ["beijing"]}, } - openai_client.send_request(request_config, request_num=get_max_batch_size()) + openai_client.send_omni_request(request_config, request_num=get_max_batch_size()) diff --git a/tests/e2e/online_serving/test_qwen3_omni_expansion.py b/tests/e2e/online_serving/test_qwen3_omni_expansion.py index fd7e0704c..a28039888 100644 --- a/tests/e2e/online_serving/test_qwen3_omni_expansion.py +++ b/tests/e2e/online_serving/test_qwen3_omni_expansion.py @@ -12,6 +12,7 @@ from pathlib import Path import pytest from tests.conftest import ( + OmniServerParams, dummy_messages_from_mix_data, generate_synthetic_audio, generate_synthetic_image, @@ -50,7 +51,9 @@ def get_chunk_config(default_path): default_path = str(Path(__file__).parent.parent / "stage_configs" / "qwen3_omni_ci.yaml") stage_configs = [default_path, get_chunk_config(default_path)] # Create parameter combinations for model and stage config -test_params = [(model, stage_config) for model in models for stage_config in stage_configs] +test_params = [ + OmniServerParams(model=model, stage_config_path=stage_config) for model in models for stage_config in stage_configs +] def get_system_prompt(): @@ -106,7 +109,7 @@ def test_text_to_audio_001(omni_server, openai_client) -> None: "key_words": {"text": ["beijing"]}, } - openai_client.send_request(request_config) + openai_client.send_omni_request(request_config) @pytest.mark.advanced_model @@ -128,7 +131,7 @@ def test_text_to_text_audio_001(omni_server, openai_client) -> None: "key_words": {"text": ["beijing"]}, } - openai_client.send_request(request_config, request_num=get_max_batch_size()) + openai_client.send_omni_request(request_config, request_num=get_max_batch_size()) @pytest.mark.advanced_model @@ -153,7 +156,7 @@ def test_image_to_text_001(omni_server, openai_client) -> None: "key_words": {"image": IMAGE_KEY}, } - openai_client.send_request(request_config) + openai_client.send_omni_request(request_config) @pytest.mark.advanced_model @@ -177,7 +180,7 @@ def test_image_to_audio_001(omni_server, openai_client) -> None: "key_words": {"image": IMAGE_KEY}, } - openai_client.send_request(request_config) + openai_client.send_omni_request(request_config) @pytest.mark.advanced_model @@ -201,7 +204,7 @@ def test_image_to_text_audio_001(omni_server, openai_client) -> None: "key_words": {"image": IMAGE_KEY}, } - openai_client.send_request(request_config, request_num=get_max_batch_size()) + openai_client.send_omni_request(request_config, request_num=get_max_batch_size()) @pytest.mark.advanced_model @@ -225,7 +228,7 @@ def test_video_to_text_001(omni_server, openai_client) -> None: "key_words": {"video": VIDEO_KEY}, } - openai_client.send_request(request_config) + openai_client.send_omni_request(request_config) @pytest.mark.advanced_model @@ -249,7 +252,7 @@ def test_video_to_audio_001(omni_server, openai_client) -> None: "key_words": {"video": VIDEO_KEY}, } - openai_client.send_request(request_config) + openai_client.send_omni_request(request_config) @pytest.mark.advanced_model @@ -273,7 +276,7 @@ def test_video_to_text_audio_001(omni_server, openai_client) -> None: "key_words": {"video": VIDEO_KEY}, } - openai_client.send_request(request_config, request_num=get_max_batch_size()) + openai_client.send_omni_request(request_config, request_num=get_max_batch_size()) @pytest.mark.advanced_model @@ -298,7 +301,7 @@ def test_text_audio_to_text_audio_001(omni_server, openai_client) -> None: "key_words": {"audio": AUDIO_KEY}, } - openai_client.send_request(request_config) + openai_client.send_omni_request(request_config) @pytest.mark.advanced_model @@ -324,7 +327,7 @@ def test_text_image_to_text_audio_001(omni_server, openai_client) -> None: "key_words": {"image": IMAGE_KEY}, } - openai_client.send_request(request_config) + openai_client.send_omni_request(request_config) @pytest.mark.advanced_model @@ -351,7 +354,7 @@ def test_text_video_to_text_audio_001(omni_server, openai_client) -> None: "key_words": {"video": VIDEO_KEY}, } - openai_client.send_request(request_config) + openai_client.send_omni_request(request_config) @pytest.mark.skip(reason="There is a known issue with shape mismatch error.") @@ -383,4 +386,4 @@ def test_mix_to_text_audio_001(omni_server, openai_client) -> None: "stream": True, "key_words": {"audio": AUDIO_KEY, "image": IMAGE_KEY, "video": VIDEO_KEY}, } - openai_client.send_request(request_config, request_num=get_max_batch_size()) + openai_client.send_omni_request(request_config, request_num=get_max_batch_size()) diff --git a/tests/e2e/online_serving/test_qwen_image_edit_expansion.py b/tests/e2e/online_serving/test_qwen_image_edit_expansion.py new file mode 100644 index 000000000..4501569aa --- /dev/null +++ b/tests/e2e/online_serving/test_qwen_image_edit_expansion.py @@ -0,0 +1,164 @@ +""" +Comprehensive tests of diffusion features that are available in online serving mode +and are supported by the following models: +- Qwen-Image-Edit: single image input +- Qwen-Image-Edit-2509: two image inputs +""" + +import pytest + +from tests.conftest import ( + OmniServer, + OmniServerParams, + OpenAIClientHandler, + dummy_messages_from_mix_data, + generate_synthetic_image, +) +from tests.utils import hardware_marks + +EDIT_PROMPT = "Transform this modern, geometrist image into a Vincent van Gogh style impressionist painting." +MULTI_EDIT_PROMPT = ( + "Transform the first image into a Dadaism collage art. " + "Transform the second image into a Vincent van Gogh style painting. " + "Then juxtapose the two transformed images into a single artwork for visual contrast." +) +NEGATIVE_PROMPT = "blurry, low quality, modern, geometrist" +SINGLE_CARD_FEATURE_MARKS = hardware_marks(res={"cuda": "H100"}) +PARALLEL_FEATURE_MARKS = hardware_marks(res={"cuda": "H100"}, num_cards=2) + + +# This test file targets two models, so I write a helper function. +# If a similar test only involves one model, one can just define a global list variable. +def _get_diffusion_feature_cases(model: str): + return [ + pytest.param( + OmniServerParams( + model=model, + server_args=[ + "--cache-backend", + "tea_cache", # [TODO] may consider changing to cache_dit after #1779 is resolved. Currently cache_dit and layerwise offload cannot work together. + "--enable-layerwise-offload", + ], + ), + id="single_card_001", + marks=SINGLE_CARD_FEATURE_MARKS, + ), + pytest.param( + OmniServerParams( + model=model, + server_args=[ + "--cache-backend", + "cache_dit", + "--ulysses-degree", + "2", + ], + ), + id="parallel_001", + marks=PARALLEL_FEATURE_MARKS, + ), + pytest.param( + OmniServerParams( + model=model, + server_args=[ + "--cache-backend", + "cache_dit", + "--ring", + "2", + ], + ), + id="parallel_002", + marks=PARALLEL_FEATURE_MARKS, + ), + pytest.param( + OmniServerParams( + model=model, + server_args=[ + "--cache-backend", + "tea_cache", + "--cfg-parallel-size", + "2", + ], + ), + id="parallel_003", + marks=PARALLEL_FEATURE_MARKS, + ), + pytest.param( + OmniServerParams( + model=model, + server_args=[ + "--cache-backend", + "cache_dit", + "--tensor-parallel-size", + "2", + "--vae-patch-parallel-size", + "2", + "--vae-use-tiling", + ], + ), + id="parallel_004", + marks=PARALLEL_FEATURE_MARKS, + ), + ] + + +@pytest.mark.advanced_model +@pytest.mark.diffusion +@pytest.mark.parametrize( + "omni_server", + _get_diffusion_feature_cases("Qwen/Qwen-Image-Edit"), + indirect=True, +) +def test_qwen_image_edit(omni_server: OmniServer, openai_client: OpenAIClientHandler): + """Test all diffusion features with Qwen-Image-Edit in regular end-user scenarios.""" + image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(512, 512)['base64']}" + + messages = dummy_messages_from_mix_data(image_data_url=image_data_url, content_text=EDIT_PROMPT) + + # CFG parallel is only activated when a negative prompt and true_cfg_scale > 1.0 are both present + request_config = { + "model": omni_server.model, + "messages": messages, + "extra_body": { + "height": 512, + "width": 512, + "num_inference_steps": 2, + "negative_prompt": NEGATIVE_PROMPT, + "true_cfg_scale": 4.0, + "seed": 42, + }, + } + + openai_client.send_diffusion_request(request_config) + + +@pytest.mark.advanced_model +@pytest.mark.diffusion +@pytest.mark.parametrize( + "omni_server", + _get_diffusion_feature_cases("Qwen/Qwen-Image-Edit-2509"), + indirect=True, +) +def test_qwen_image_edit_2509(omni_server: OmniServer, openai_client: OpenAIClientHandler): + """Test all diffusion features with Qwen-Image-Edit-2509 in regular end-user scenarios.""" + image_data_url_1 = f"data:image/jpeg;base64,{generate_synthetic_image(512, 512)['base64']}" + image_data_url_2 = f"data:image/jpeg;base64,{generate_synthetic_image(512, 512)['base64']}" + + messages = dummy_messages_from_mix_data( + image_data_url=[image_data_url_1, image_data_url_2], content_text=MULTI_EDIT_PROMPT + ) + + # CFG parallel is only activated when a negative prompt and true_cfg_scale > 1.0 are both present + request_config = { + "model": omni_server.model, + "messages": messages, + "extra_body": { + "height": 512, + "width": 512, + "num_inference_steps": 2, + "negative_prompt": NEGATIVE_PROMPT, + "true_cfg_scale": 4.0, + "seed": 42, + }, + } + + openai_client.send_diffusion_request(request_config) diff --git a/tests/examples/online_serving/test_qwen2_5_omni.py b/tests/examples/online_serving/test_qwen2_5_omni.py index 8e08d5bc5..58b011f19 100644 --- a/tests/examples/online_serving/test_qwen2_5_omni.py +++ b/tests/examples/online_serving/test_qwen2_5_omni.py @@ -12,7 +12,7 @@ from pathlib import Path import pytest -from tests.conftest import convert_audio_file_to_text, cosine_similarity_text +from tests.conftest import OmniServerParams, convert_audio_file_to_text, cosine_similarity_text from tests.utils import hardware_test models = ["Qwen/Qwen2.5-Omni-7B"] @@ -22,7 +22,11 @@ stage_configs = [str(Path(__file__).parent.parent.parent / "e2e" / "stage_config example_dir = str(Path(__file__).parent.parent.parent.parent / "examples" / "online_serving" / "qwen2_5_omni") # Create parameter combinations for model and stage config -test_params = [(8091, model, stage_config) for model in models for stage_config in stage_configs] +test_params = [ + OmniServerParams(model=model, port=8091, stage_config_path=stage_config) + for model in models + for stage_config in stage_configs +] def run_cmd(command): diff --git a/tests/examples/online_serving/test_qwen3_omni.py b/tests/examples/online_serving/test_qwen3_omni.py index c7354905e..568f1f9b4 100644 --- a/tests/examples/online_serving/test_qwen3_omni.py +++ b/tests/examples/online_serving/test_qwen3_omni.py @@ -12,7 +12,7 @@ from pathlib import Path import pytest -from tests.conftest import convert_audio_file_to_text, cosine_similarity_text +from tests.conftest import OmniServerParams, convert_audio_file_to_text, cosine_similarity_text from tests.utils import hardware_test models = ["Qwen/Qwen3-Omni-30B-A3B-Instruct"] @@ -22,7 +22,11 @@ stage_configs = [str(Path(__file__).parent.parent.parent / "e2e" / "stage_config example_dir = str(Path(__file__).parent.parent.parent.parent / "examples" / "online_serving" / "qwen3_omni") # Create parameter combinations for model and stage config -test_params = [(8091, model, stage_config) for model in models for stage_config in stage_configs] +test_params = [ + OmniServerParams(model=model, port=8091, stage_config_path=stage_config) + for model in models + for stage_config in stage_configs +] def run_cmd(command): diff --git a/tests/utils.py b/tests/utils.py index e4e3e14c5..e8a9d5c05 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -443,27 +443,11 @@ def npu_marks(*, res: str, num_cards: int): return [mark for mark in [test_platform, test_resource, test_distributed] if mark is not None] -def hardware_test(*, res: dict[str, str], num_cards: int | dict[str, int] = 1): +def hardware_marks(*, res: dict[str, str], num_cards: int | dict[str, int] = 1): """ - Decorate a test for multiple hardware platforms with a single call. - Automatically wraps the test with @create_new_process_for_each_test() for distributed tests. - - Args: - res: Mapping from platform to resource type. Supported platforms/resources: - - cuda: L4, H100 - - rocm: MI325 - - npu: A2, A3 - num_cards: Number of cards required. Can be: - - int: same card count for all platforms (default: 1) - - dict: per-platform card count, e.g., {"cuda": 2, "rocm": 2} - - Example: - @hardware_test( - res={"cuda": "L4", "rocm": "MI325", "npu": "A2"}, - num_cards={"cuda": 2, "rocm": 2, "npu": 2}, - ) - def test_multi_platform(): - ... + Get a collection of pytest marks to apply for `@hardware_test`, + including CUDA, ROCm, and NPU, + based on the specified platforms and resources. """ # Validate platforms # Don't validate platform details in this decorator @@ -486,7 +470,7 @@ def hardware_test(*, res: dict[str, str], num_cards: int | dict[str, int] = 1): num_cards_dict[platform] = 1 # Collect marks from all platforms - all_marks: list[Callable[[Callable[_P, None]], Callable[_P, None]]] = [] + all_marks: list[pytest.MarkDecorator] = [] for platform, resource in res.items(): cards = num_cards_dict[platform] if platform == "cuda" or platform == "rocm": @@ -496,6 +480,32 @@ def hardware_test(*, res: dict[str, str], num_cards: int | dict[str, int] = 1): else: raise ValueError(f"Unsupported platform: {platform}") all_marks.extend(marks) + return all_marks + + +def hardware_test(*, res: dict[str, str], num_cards: int | dict[str, int] = 1): + """ + Decorate a test for multiple hardware platforms with a single call. + Automatically wraps the test with @create_new_process_for_each_test() for distributed tests. + + Args: + res: Mapping from platform to resource type. Supported platforms/resources: + - cuda: L4, H100 + - rocm: MI325 + - npu: A2, A3 + num_cards: Number of cards required. Can be: + - int: same card count for all platforms (default: 1) + - dict: per-platform card count, e.g., {"cuda": 2, "rocm": 2} + + Example: + @hardware_test( + res={"cuda": "L4", "rocm": "MI325", "npu": "A2"}, + num_cards={"cuda": 2, "rocm": 2, "npu": 2}, + ) + def test_multi_platform(): + ... + """ + all_marks = hardware_marks(res=res, num_cards=num_cards) def wrapper(f: Callable[_P, None]) -> Callable[_P, None]: func = f From 74eedd3ef1fef6f9a865975abd6f9d260ed59c2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zeyu=20Huang=20=7C=20=E9=BB=83=E6=BE=A4=E5=AE=87?= <11222265+fhfuih@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:22:45 +0800 Subject: [PATCH 07/38] [Frontend] ComfyUI video & LoRA support (#1596) Signed-off-by: Huang, Zeyu <11222265+fhfuih@users.noreply.github.com> --- apps/ComfyUI-vLLM-Omni/README.md | 17 +- apps/ComfyUI-vLLM-Omni/__init__.py | 9 + .../comfyui_vllm_omni/nodes.py | 166 +++++- .../comfyui_vllm_omni/utils/api_client.py | 88 ++- .../comfyui_vllm_omni/utils/format.py | 70 +++ .../comfyui_vllm_omni/utils/types.py | 4 + .../comfyui_vllm_omni/utils/validators.py | 2 +- .../docs/images/comfyui-video-generation.jpg | Bin 0 -> 132038 bytes .../vLLM-Omni Video Generation.json | 513 ++++++++++++++++++ docs/features/comfyui.md | 5 +- tests/comfyui/conftest.py | 7 + tests/comfyui/test_comfyui_integration.py | 171 +++++- .../openai_api/test_image_server.py | 1 - 13 files changed, 1038 insertions(+), 15 deletions(-) create mode 100644 apps/ComfyUI-vLLM-Omni/docs/images/comfyui-video-generation.jpg create mode 100644 apps/ComfyUI-vLLM-Omni/example_workflows/vLLM-Omni Video Generation.json diff --git a/apps/ComfyUI-vLLM-Omni/README.md b/apps/ComfyUI-vLLM-Omni/README.md index 23bbe5b72..54f2fdf2e 100644 --- a/apps/ComfyUI-vLLM-Omni/README.md +++ b/apps/ComfyUI-vLLM-Omni/README.md @@ -47,6 +47,7 @@ If no, check your shell running the ComfyUI process. There may be some error mes This extension offers the following nodes based on the output modalities (at **ComfyUI sidebar -> Node Library**): - **Generate Image** for text-to-image and image-to-image tasks +- **Generate Video** for text-to-video and image-to-video tasks - **Multimodality Understanding** for multimodality-to-text and multimodality-to-audio tasks - **TTS** and **TTS Voice Clone** for TTS tasks @@ -59,7 +60,7 @@ To build a simple workflow yourself, - Drag a generation node onto the canvas. - Depending on your need, grab built-in multimedia file loader nodes, such as **image->Load Image**, **image->video->Load Video**, **audio->Load Audio** -- Depending on your need, grab built-in multimedia file preview nodes, such as **image->Preview Image**, **image->video->Save Video**, **audio->Preview Audio**. For text output, you can install [ComfyUI-Custom-Scripts plugin](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/) and grab its **utils->Show Text 🐍** node. +- Depending on your need, grab built-in multimedia file preview nodes, such as **image->Preview Image**, **image->video->Save Video**, **audio->Preview Audio**, **utils->Preview as Text**. - If you want to tune sampling parameters, grab corresponding nodes from **vLLM-Omni-> Sampling Params**. - For multi-stage models, you can connect multiple **AR Sampling Params** and **Diffusion Sampling Params** nodes to a **Multi-Stage Sampling Params List** node, and connect this node to the generation node. - For some multi-stage models like BAGEL, [only one stage's sampling parameters are exposed and tunable via vLLM-Omni's online serving API](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/examples/online_serving/bagel/). Thus, these models are treated as single-stage ones. Please check the vLLM-Omni documentation on how to correctly set each model's sampling parameters. @@ -104,6 +105,20 @@ You can configure per-stage sampling parameters for multi-stage models. > [!TIP] > The node automatically choose text-to-image or image-to-image API endpoints depending on whether you connect an image input or not. +### Text-to-video and image-to-video generation (e.g., Wan) + +(Also available at **ComfyUI sidebar->Template->vLLM-Omni->vLLM-Omni Video Generation**) + +

+ + + vLLM-Omni video generation + +

+ +> [!TIP] +> The node automatically choose text-to-video or image-to-video API endpoints depending on whether you connect an image input or not. + ### TTS (e.g., Qwen TTS series) (Also available at **ComfyUI sidebar->Template->vLLM-Omni->vLLM-Omni TTS**) diff --git a/apps/ComfyUI-vLLM-Omni/__init__.py b/apps/ComfyUI-vLLM-Omni/__init__.py index 641824add..e89b2ca1e 100644 --- a/apps/ComfyUI-vLLM-Omni/__init__.py +++ b/apps/ComfyUI-vLLM-Omni/__init__.py @@ -14,17 +14,21 @@ from .comfyui_vllm_omni.nodes import ( VLLMOmniARSampling, VLLMOmniDiffusionSampling, VLLMOmniGenerateImage, + VLLMOmniGenerateVideo, VLLMOmniQwenTTSParams, + VLLMOmniRemoteLoRA, VLLMOmniSamplingParamsList, VLLMOmniTTS, VLLMOmniUnderstanding, VLLMOmniVoiceClone, + VLLMOmniWanParams, ) # A dictionary that contains all nodes you want to export with their names NODE_CLASS_MAPPINGS = { # === Generation === "VLLMOmniGenerateImage": VLLMOmniGenerateImage, + "VLLMOmniGenerateVideo": VLLMOmniGenerateVideo, "VLLMOmniUnderstanding": VLLMOmniUnderstanding, "VLLMOmniTTS": VLLMOmniTTS, "VLLMOmniVoiceClone": VLLMOmniVoiceClone, @@ -32,13 +36,16 @@ NODE_CLASS_MAPPINGS = { "VLLMOmniARSampling": VLLMOmniARSampling, "VLLMOmniDiffusionSampling": VLLMOmniDiffusionSampling, "VLLMOmniSamplingParamsList": VLLMOmniSamplingParamsList, + "VLLMOmniRemoteLoRA": VLLMOmniRemoteLoRA, "VLLMOmniQwenTTSParams": VLLMOmniQwenTTSParams, + "VLLMOmniWanParams": VLLMOmniWanParams, } # A dictionary that contains the friendly/humanly readable titles for the nodes NODE_DISPLAY_NAME_MAPPINGS = { # === Generation === "VLLMOmniGenerateImage": "Generate Image", + "VLLMOmniGenerateVideo": "Generate Video", "VLLMOmniUnderstanding": "Multimodality Understanding", "VLLMOmniTTS": "TTS (Text to Speech)", "VLLMOmniVoiceClone": "TTS Voice Cloning", @@ -46,7 +53,9 @@ NODE_DISPLAY_NAME_MAPPINGS = { "VLLMOmniARSampling": "AR Sampling Params", "VLLMOmniDiffusionSampling": "Diffusion Sampling Params", "VLLMOmniSamplingParamsList": "Multi-Stage Sampling Params List", + "VLLMOmniRemoteLoRA": "LoRA", "VLLMOmniQwenTTSParams": "Qwen TTS Params", + "VLLMOmniWanParams": "Wan Video Params", } WEB_DIRECTORY = "./web" diff --git a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/nodes.py b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/nodes.py index 5caa3869e..bfea93998 100644 --- a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/nodes.py +++ b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/nodes.py @@ -11,6 +11,7 @@ from .utils.types import ( AutoregressionSamplingParams, DiffusionSamplingParams, QwenTTSModelSpecificParams, + WanModelSpecificParams, ) from .utils.validators import ( add_sampling_parameters_to_stage, @@ -56,6 +57,7 @@ class VLLMOmniGenerateImage(_VLLMOmniGenerateBase): # "video": ("VIDEO",), # "audio": ("AUDIO",), "sampling_params": ("SAMPLING_PARAMS",), + "lora": ("REMOTE_LORA",), }, } @@ -76,9 +78,11 @@ class VLLMOmniGenerateImage(_VLLMOmniGenerateBase): audio: AudioInput | None = None, # Hidden & unused video: VideoInput | None = None, # Hidden & unused sampling_params: dict | list[dict] | None = None, + lora: dict | None = None, **kwargs, ): - logger.info("Uncaught kwargs: %s", kwargs) + if kwargs: + logger.info("Uncaught kwargs: %s", kwargs) logger.debug("Got sampling params: %s", sampling_params) validate_model_and_sampling_params_types(model, sampling_params) if image is None and mask is not None: @@ -105,6 +109,7 @@ class VLLMOmniGenerateImage(_VLLMOmniGenerateBase): height=height, negative_prompt=negative_prompt, sampling_params=sampling_params, + lora=lora, ) return (output,) elif image is not None and audio is None and video is None: @@ -119,6 +124,7 @@ class VLLMOmniGenerateImage(_VLLMOmniGenerateBase): negative_prompt=negative_prompt, mask=mask, sampling_params=sampling_params, + lora=lora, ) return (output,) @@ -136,11 +142,91 @@ class VLLMOmniGenerateImage(_VLLMOmniGenerateBase): audio=audio, video=video, sampling_params=sampling_params, + lora=lora, ) return (output,) +class VLLMOmniGenerateVideo(_VLLMOmniGenerateBase): + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "url": ("STRING", {"default": "http://localhost:8000/v1"}), + "model": ("STRING", {"default": "Wan-AI/Wan2.2-T2V-A14B-Diffusers"}), + "prompt": ("STRING", {"multiline": True}), + "negative_prompt": ("STRING", {"multiline": True, "default": ""}), + "width": ("INT", {"default": 832, "min": 1}), + "height": ("INT", {"default": 480, "min": 1}), + "fps": ("INT", {"default": 16, "min": 1}), + "num_frames": ("INT", {"default": 41, "min": 1}), + }, + "optional": { + "image": ("IMAGE",), + "sampling_params": ("SAMPLING_PARAMS",), + "lora": ("REMOTE_LORA",), + "model_params": ("VIDEO_PARAMS",), + }, + } + + RETURN_TYPES = ("VIDEO",) + RETURN_NAMES = ("video",) + FUNCTION = "generate" + + async def generate( + self, + url: str, + model: str, + prompt: str, + width: int, + height: int, + fps: int, + num_frames: int, + negative_prompt: str | None = None, + image: torch.Tensor | None = None, + sampling_params: dict | list[dict] | None = None, + model_params: dict | None = None, + lora: dict | None = None, + **kwargs, + ): + if kwargs: + logger.info("Uncaught kwargs: %s", kwargs) + logger.debug("Got sampling params: %s", sampling_params) + logger.debug("Got model params: %s", model_params) + validate_model_and_sampling_params_types(model, sampling_params) + + # Currently, all video generation models are single-stage diffusion models + if isinstance(sampling_params, list): + if len(sampling_params) != 1: + raise ValueError( + "Video generation expects a single sampling params group. " + "Please provide one Diffusion sampling node." + ) + sampling_params = sampling_params[0] + + if sampling_params is not None: + sampling_params.pop("type", None) # internal fields + if model_params is not None: + model_params.pop("type", None) # internal fields + + client = VLLMOmniClient(url) + output = await client.generate_video( + model=model, + prompt=prompt, + image=image, # image present => i2v, absent => t2v + width=width, + height=height, + num_frames=num_frames, + fps=fps, + negative_prompt=negative_prompt, + sampling_params=sampling_params, + lora=lora, + model_params=model_params, + ) + return (output,) + + class VLLMOmniUnderstanding(_VLLMOmniGenerateBase): @classmethod def INPUT_TYPES(cls): @@ -188,7 +274,8 @@ class VLLMOmniUnderstanding(_VLLMOmniGenerateBase): use_audio_in_video: bool = True, **kwargs, ) -> tuple[str, AudioInput]: - logger.info("Uncaught kwargs: %s", kwargs) + if kwargs: + logger.info("Uncaught kwargs: %s", kwargs) logger.debug("Got sampling params: %s", sampling_params) validate_model_and_sampling_params_types(model, sampling_params) @@ -547,6 +634,52 @@ class VLLMOmniSamplingParamsList: return (params,) +class VLLMOmniRemoteLoRA: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "local_path": ("STRING", {"default": ""}), + "name": ("STRING", {"default": ""}), + "scale": ( + "FLOAT", + {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.1}, + ), + "int_id": ( + "INT", + { + "default": 0, + "min": 0, + "step": 1, + "tooltip": "0 means it is not set and the server can derive it.", + }, + ), + } + } + + RETURN_TYPES = ("REMOTE_LORA",) + RETURN_NAMES = ("lora",) + FUNCTION = "get_lora" + CATEGORY = "vLLM-Omni" + + @classmethod + def VALIDATE_INPUTS(cls, local_path, name) -> str | Literal[True]: + if not local_path.strip() or not name.strip(): + return "Both local_path and name must be provided." + return True + + def get_lora(self, local_path: str, name: str, scale: float, int_id: int): + local_path = local_path.strip() + name = name.strip() + lora = { + "local_path": local_path or None, + "name": name or None, + "scale": float(scale), + "int_id": int(int_id) if int_id > 0 else None, + } + return (lora,) + + class VLLMOmniQwenTTSParams: @classmethod def INPUT_TYPES(cls): @@ -572,3 +705,32 @@ class VLLMOmniQwenTTSParams: def get_params(self, **kwargs): return (QwenTTSModelSpecificParams(kwargs),) + + +class VLLMOmniWanParams: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "guidance_scale_2": ( + "FLOAT", + {"default": 4.0, "min": 0.0, "max": 20.0, "step": 0.1}, + ), + "boundary_ratio": ( + "FLOAT", + {"default": 0.875, "min": 0.0, "max": 1.0, "step": 0.001}, + ), + "flow_shift": ( + "FLOAT", + {"default": 5.0, "min": 0.0, "max": 100.0, "step": 0.1}, + ), + } + } + + RETURN_TYPES = ("VIDEO_PARAMS",) + RETURN_NAMES = ("Wan video params",) + FUNCTION = "get_params" + CATEGORY = "vLLM-Omni/Video Params" + + def get_params(self, **kwargs): + return (WanModelSpecificParams(kwargs),) diff --git a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/api_client.py b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/api_client.py index d340073bc..2c936d33f 100644 --- a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/api_client.py +++ b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/api_client.py @@ -6,6 +6,7 @@ The image generation part is derived from dougbtv/comfyui-vllm-omni by Doug (@do Original source at https://github.com/dougbtv/comfyui-vllm-omni, distributed under the MIT License. """ +import json from typing import Any import aiohttp @@ -17,6 +18,7 @@ from .format import ( audio_to_base64, base64_to_audio, base64_to_image_tensor, + base64_to_video, bytes_to_audio, image_tensor_to_base64, image_tensor_to_png_bytes, @@ -30,7 +32,7 @@ logger = get_logger(__name__) class VLLMOmniClient: - def __init__(self, base_url: str, timeout: float = 300.0): + def __init__(self, base_url: str, timeout: float | None = None): self.base_url = base_url self.timeout = aiohttp.ClientTimeout(total=timeout) @@ -43,12 +45,13 @@ class VLLMOmniClient: height: int, negative_prompt: str | None = None, sampling_params: dict | None = None, + lora: dict | None = None, ) -> torch.Tensor: """Run text-to-image generation via DALLE API""" await self._check_model_exist(model) size = f"{width}x{height}" - payload = { + payload: dict[str, Any] = { "model": model, "prompt": prompt, "size": size, @@ -58,6 +61,8 @@ class VLLMOmniClient: payload["negative_prompt"] = negative_prompt if sampling_params is not None: payload.update(sampling_params) + if lora is not None: + payload["lora"] = lora logger.debug("img gen payload: %s", payload) url = self.base_url + "/images/generations" @@ -110,6 +115,7 @@ class VLLMOmniClient: negative_prompt: str | None = None, mask: torch.Tensor | None = None, sampling_params: dict | None = None, + lora: dict | None = None, ) -> torch.Tensor: """Run image editing via DALLE API""" await self._check_model_exist(model) @@ -131,6 +137,8 @@ class VLLMOmniClient: if sampling_params is not None: for k, v in sampling_params.items(): form.add_field(k, str(v)) + if lora is not None: + form.add_field("lora", json.dumps(lora, ensure_ascii=False)) if mask is not None: mask_filename = "mask.png" form.add_field( @@ -183,6 +191,7 @@ class VLLMOmniClient: audio: AudioInput | None = None, video: VideoInput | None = None, sampling_params: dict | list[dict] | None = None, + lora: dict | None = None, ) -> torch.Tensor: payload = VLLMOmniClient._prepare_chat_completion_messages( model=model, @@ -193,6 +202,8 @@ class VLLMOmniClient: video=video, sampling_params=sampling_params, modalities=["image"], + # === below are additional `extra_body` fields, handled by **kwargs === + lora=lora, ) choices = await self._generate_base_chat_completion(model, payload) @@ -206,6 +217,79 @@ class VLLMOmniClient: return torch.stack(image_tensors, dim=0) + async def generate_video( + self, + *, + model: str, + prompt: str, + width: int, + height: int, + num_frames: int, + fps: int, + negative_prompt: str | None = None, + image: torch.Tensor | None = None, + sampling_params: dict | None = None, + model_params: dict | None = None, + lora: dict | None = None, + **extra_body, + ) -> VideoInput: + form = aiohttp.FormData() + form.add_field("model", model) + form.add_field("prompt", prompt) + form.add_field("width", str(width)) + form.add_field("height", str(height)) + form.add_field("num_frames", str(num_frames)) + form.add_field("fps", str(fps)) + if negative_prompt: + form.add_field("negative_prompt", negative_prompt) + if sampling_params is not None: + for k, v in sampling_params.items(): + form.add_field(k, str(v)) + if model_params is not None: + for k, v in model_params.items(): + form.add_field(k, str(v)) + if lora is not None: + form.add_field("lora", json.dumps(lora, ensure_ascii=False)) + if extra_body: + form.add_field("extra_body", json.dumps(extra_body, ensure_ascii=False)) + + if image is not None: + image_filename = "image.png" # Required for multipart form + form.add_field( + "input_reference", + image_tensor_to_png_bytes(image, image_filename), + filename=image_filename, + content_type="image/png", + ) + + url = self.base_url + "/videos" + async with aiohttp.ClientSession(timeout=self.timeout) as session: + try: + async with session.post(url, data=form) as response: + if not response.ok: + error_text = await response.text() + raise (ValueError if response.status < 500 else RuntimeError)( + f"vLLM-Omni API returned status {response.status}: {error_text}" + ) + + try: + data = await response.json() + except aiohttp.ContentTypeError as e: + raise RuntimeError(f"Invalid JSON response from vLLM-Omni: {e}") + except aiohttp.ClientError as e: + raise RuntimeError(f"Network error connecting to vLLM-Omni at {url}: {e}") + + if "data" not in data: + raise RuntimeError("API response missing 'data' field - expected OpenAI DALL-E format") + if not data["data"]: + raise RuntimeError("API returned empty data array") + try: + base64_str = data["data"][0]["b64_json"] + except (KeyError, IndexError): + raise RuntimeError("API response missing 'b64_json' field in first data item") + + return base64_to_video(base64_str) + async def generate_understanding_chat_completion( self, *, diff --git a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/format.py b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/format.py index 0ddf86f9c..642c83c8d 100644 --- a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/format.py +++ b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/format.py @@ -6,12 +6,17 @@ Original source at https://github.com/dougbtv/comfyui-vllm-omni, distributed und import base64 import mimetypes +from fractions import Fraction from io import BytesIO import av import numpy as np import torch +from av.audio.frame import AudioFrame +from av.audio.resampler import AudioResampler +from av.video.frame import VideoFrame from comfy_api.input import AudioInput, VideoInput +from comfy_api.latest import InputImpl, Types from comfy_extras import nodes_audio from PIL import Image @@ -138,6 +143,71 @@ def video_to_base64(video: VideoInput, filename: str = "video.mp4") -> str: return f"data:{mime_type};base64,{base64_str}" +def base64_to_video(base64_str: str) -> VideoInput: + if base64_str.startswith("data:video"): + _, base64_str = base64_str.split(",", 1) + + try: + video_bytes = base64.b64decode(base64_str) + except Exception as e: + raise ValueError(f"Invalid base64 string: {e}") + + video_buffer = BytesIO(video_bytes) + + try: + with av.open(video_buffer, mode="r") as container: + video_stream = next((s for s in container.streams if s.type == "video"), None) + if video_stream is None: + raise ValueError("No video stream found in decoded payload.") + + frames = [] + for frame in container.decode(video_stream): + if not isinstance(frame, VideoFrame): + continue + image = frame.to_ndarray(format="rgb24") + frames.append(torch.from_numpy(image).float() / 255.0) + + if len(frames) == 0: + raise ValueError("No video frames found in decoded payload.") + + images = torch.stack(frames, dim=0) + frame_rate = Fraction(video_stream.average_rate) if video_stream.average_rate else Fraction(1) + + audio: AudioInput | None = None + if len(container.streams.audio): + audio_stream = container.streams.audio[-1] + audio_frames = [] + resampler = AudioResampler(format="fltp") + for frame in container.decode(audio_stream): + if not isinstance(frame, AudioFrame): + continue + resampled = resampler.resample(frame) + if not isinstance(resampled, list): + resampled = [resampled] + for audio_frame in resampled: + if audio_frame is not None: + audio_frames.append(audio_frame.to_ndarray()) + + if len(audio_frames) > 0: + audio_data = np.concatenate(audio_frames, axis=1) + sample_rate = int(audio_stream.sample_rate) if audio_stream.sample_rate else 1 + audio = { + "waveform": torch.from_numpy(audio_data).unsqueeze(0), + "sample_rate": sample_rate, + } + + components = Types.VideoComponents( + images=images, + frame_rate=frame_rate, + audio=audio, + metadata=container.metadata if container.metadata else None, + ) + except Exception as e: + raise RuntimeError(f"Failed to decode video: {e}") + + return InputImpl.VideoFromComponents(components) + + def audio_to_bytes(audio: AudioInput, filename: str = "audio.mp3", quality: str = "128k") -> BytesIO: waveform = audio["waveform"][0] # Shape: (C, T) sample_rate = audio["sample_rate"] diff --git a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/types.py b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/types.py index 6cc79f3ed..c7d254eb9 100644 --- a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/types.py +++ b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/types.py @@ -23,6 +23,10 @@ class QwenTTSModelSpecificParams(dict): pass +class WanModelSpecificParams(dict): + pass + + class ModelMode(Enum): IMAGE_GENERATION = auto() VIDEO_GENERATION = auto() diff --git a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/validators.py b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/validators.py index 2abdd2100..c5ac4bdf4 100644 --- a/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/validators.py +++ b/apps/ComfyUI-vLLM-Omni/comfyui_vllm_omni/utils/validators.py @@ -16,7 +16,7 @@ def validate_model_and_sampling_params_types( # Skip if no spec or no sampling params pipeline_spec, _ = lookup_model_spec(model_name) if pipeline_spec is None: - logger.info("skipping sampling params check because spec is not found") + logger.info(f"skipping sampling params check because spec for {model_name} is not found") return if sampling_param_list is None: return diff --git a/apps/ComfyUI-vLLM-Omni/docs/images/comfyui-video-generation.jpg b/apps/ComfyUI-vLLM-Omni/docs/images/comfyui-video-generation.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a449a4eee70b9fdcb52bf4485e0e747a7f600b47 GIT binary patch literal 132038 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 z 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 Templates -> vLLM-Omni**) @@ -59,7 +60,7 @@ To build a simple workflow yourself, - Drag a generation node onto the canvas. - Depending on your need, grab built-in multimedia file loader nodes, such as **image->Load Image**, **image->video->Load Video**, **audio->Load Audio** -- Depending on your need, grab built-in multimedia file preview nodes, such as **image->Preview Image**, **image->video->Save Video**, **audio->Preview Audio**. For text output, you can install [ComfyUI-Custom-Scripts plugin](https://github.com/pythongosssss/ComfyUI-Custom-Scripts/) and grab its **utils->Show Text 🐍** node. +- Depending on your need, grab built-in multimedia file preview nodes, such as **image->Preview Image**, **image->video->Save Video**, **audio->Preview Audio**, **utils->Preview as Text**. - If you want to tune sampling parameters, grab corresponding nodes from **vLLM-Omni-> Sampling Params**. - For multi-stage models, you can connect multiple **AR Sampling Params** and **Diffusion Sampling Params** nodes to a **Multi-Stage Sampling Params List** node, and connect this node to the generation node. - For some multi-stage models like BAGEL, [only one stage's sampling parameters are exposed and tunable via vLLM-Omni's online serving API](https://docs.vllm.ai/projects/vllm-omni/en/latest/user_guide/examples/online_serving/bagel/). Thus, these models are treated as single-stage ones. Please check the vLLM-Omni documentation on how to correctly set each model's sampling parameters. diff --git a/tests/comfyui/conftest.py b/tests/comfyui/conftest.py index 7c9770b8e..0b4565e94 100644 --- a/tests/comfyui/conftest.py +++ b/tests/comfyui/conftest.py @@ -63,6 +63,12 @@ def _setup_comfyui_test_environment(): mock_comfy_api_input.AudioInput = AudioInput mock_comfy_api_input.VideoInput = VideoInput mock_comfy_api.input = mock_comfy_api_input + mock_comfy_api_latest = MagicMock() + mock_comfy_api_latest.Types.VideoComponents = MagicMock(side_effect=lambda **kwargs: kwargs) + mock_comfy_api_latest.InputImpl.VideoFromComponents = MagicMock( + side_effect=lambda _: VideoInput(b"mock_video_from_components") + ) + mock_comfy_api.latest = mock_comfy_api_latest def mock_load(_: str | BinaryIO): """Mock nodes_audio.load that returns a waveform tensor (channels, samples) and sample rate.""" @@ -78,5 +84,6 @@ def _setup_comfyui_test_environment(): # Install mock modules BEFORE importing any comfyui_vllm_omni code sys.modules["comfy_api"] = mock_comfy_api sys.modules["comfy_api.input"] = mock_comfy_api_input + sys.modules["comfy_api.latest"] = mock_comfy_api_latest sys.modules["comfy_extras"] = mock_comfy_extras sys.modules["comfy_extras.nodes_audio"] = mock_nodes_audio diff --git a/tests/comfyui/test_comfyui_integration.py b/tests/comfyui/test_comfyui_integration.py index 463596326..d28818ab0 100644 --- a/tests/comfyui/test_comfyui_integration.py +++ b/tests/comfyui/test_comfyui_integration.py @@ -20,11 +20,12 @@ import torch from comfy_api.input import AudioInput, VideoInput from comfyui_vllm_omni.nodes import ( VLLMOmniGenerateImage, + VLLMOmniGenerateVideo, VLLMOmniTTS, VLLMOmniUnderstanding, VLLMOmniVoiceClone, ) -from comfyui_vllm_omni.utils.types import AutoregressionSamplingParams, DiffusionSamplingParams +from comfyui_vllm_omni.utils.types import AutoregressionSamplingParams, DiffusionSamplingParams, WanModelSpecificParams from PIL import Image from vllm import SamplingParams from vllm.outputs import CompletionOutput, RequestOutput @@ -34,6 +35,8 @@ from vllm_omni.entrypoints.cli.serve import OmniServeCommand from vllm_omni.inputs.data import OmniSamplingParams from vllm_omni.outputs import OmniRequestOutput +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + class ServerCase(NamedTuple): """Parametrizing the model to serve.""" @@ -49,6 +52,7 @@ class SamplingCase(NamedTuple): kind: "SamplingKind" sampling_params: dict | list[dict] | None + lora: dict | None = None class SamplingKind(StrEnum): @@ -58,11 +62,17 @@ class SamplingKind(StrEnum): UNDERSTANDING_AR_LIST = auto() TTS_NONE = auto() TTS_DIFFUSION_SINGLE = auto() + VIDEO_NONE = auto() + VIDEO_DIFFUSION_SINGLE = auto() # Pre-defined arguments to be used in function calls during the tests IMAGE_WIDTH = 64 IMAGE_HEIGHT = 64 +VIDEO_WIDTH = 32 +VIDEO_HEIGHT = 32 +VIDEO_FPS = 8 +VIDEO_NUM_FRAMES = 5 DIFFUSION_SINGLE_SAMPLING_PARAMS = DiffusionSamplingParams( { "n": 2, @@ -102,6 +112,10 @@ AR_LIST_SAMPLING_PARAMS = [ ), ] +VIDEO_MODEL_PARAMS = WanModelSpecificParams({"guidance_scale_2": 5.0, "boundary_ratio": 0.98, "flow_shift": 12.0}) + +LORA_PARAMS = {"local_path": "test_lora_path", "name": "test_name", "scale": 0.7, "int_id": 10} + def _build_image_output(size: tuple[int, int] = (IMAGE_WIDTH, IMAGE_HEIGHT), color: str = "red") -> Image.Image: return Image.new("RGB", size, color=color) @@ -181,6 +195,16 @@ def _build_diffusion_image_output_for_images_endpoint() -> OmniRequestOutput: ) +def _build_diffusion_video_output() -> OmniRequestOutput: + # Small video: VIDEO_NUM_FRAMES frames of (VIDEO_HEIGHT x VIDEO_WIDTH) RGB, shape (F, H, W, C) + video_frames = torch.zeros((VIDEO_NUM_FRAMES, VIDEO_HEIGHT, VIDEO_WIDTH, 3), dtype=torch.float32) + return OmniRequestOutput.from_diffusion( + request_id="test_req_video", + images=[video_frames], + final_output_type="video", + ) + + def _build_diffusion_image_output_for_chat_endpoint() -> OmniRequestOutput: request_output = MagicMock() request_output.images = [_build_image_output(color="blue")] @@ -193,12 +217,40 @@ def _build_diffusion_image_output_for_chat_endpoint() -> OmniRequestOutput: ) -def _assert_sampling_param_values(received: OmniSamplingParams, expected: dict[str, Any]): +def _assert_sampling_param_values( + received: OmniSamplingParams, expected: dict[str, Any], expected_lora: dict | None = None +): for key, expected_value in expected.items(): - actual_value = getattr(received, key, None) + actual_value = getattr(received, key) assert actual_value == expected_value, ( f"Expected sampling param '{key}'={expected_value}, got {actual_value}. The received sampling params: {received}" ) + if expected_lora: + assert received.lora_request.lora_name == expected_lora["name"], ( + f"Expected lora name={(expected_lora['name'])}, got {received.lora_request.lora_name}. The received sampling params: {received}" + ) + assert received.lora_request.lora_int_id == expected_lora["int_id"], ( + f"Expected lora int_id={expected_lora['int_id']}, got {received.lora_request.lora_int_id}. The received sampling params: {received}" + ) + assert received.lora_request.lora_path == expected_lora["local_path"], ( + f"Expected lora path={expected_lora['local_path']}, got {received.lora_request.lora_path}. The received sampling params: {received}" + ) + assert received.lora_scale == expected_lora["scale"], ( + f"Expected lora scale={expected_lora['scale']}, got {received.lora_scale}. The received sampling params: {received}" + ) + + +def _assert_model_param_values(received: OmniSamplingParams, expected: dict): + for key, expected_value in expected.items(): + try: + actual_value = getattr(received, key) + expected_param_name = key + except AttributeError: + actual_value = received.extra_args.get(key, None) + expected_param_name = f'extra_args["{key}"]' + assert actual_value == expected_value, ( + f"Expected model param '{expected_param_name}'={expected_value}, got {actual_value}. The received sampling params: {received}" + ) def _build_mock_outputs(outputs: Iterable[OmniRequestOutput], sampling_case: SamplingCase, server_case: ServerCase): @@ -235,6 +287,7 @@ def _build_mock_outputs(outputs: Iterable[OmniRequestOutput], sampling_case: Sam "height": IMAGE_HEIGHT, **expected, }, + LORA_PARAMS, ) elif sampling_case.kind is SamplingKind.UNDERSTANDING_NONE: assert len(received_sampling_params_list) == 3 @@ -244,6 +297,33 @@ def _build_mock_outputs(outputs: Iterable[OmniRequestOutput], sampling_case: Sam _assert_sampling_param_values(received_sampling_params_list[i], expected) elif sampling_case.kind in {SamplingKind.TTS_NONE, SamplingKind.TTS_DIFFUSION_SINGLE}: assert len(received_sampling_params_list) == 1 + elif sampling_case.kind is SamplingKind.VIDEO_NONE: + assert len(received_sampling_params_list) == 1 + _assert_sampling_param_values( + received_sampling_params_list[0], + { + "width": VIDEO_WIDTH, + "height": VIDEO_HEIGHT, + "num_frames": VIDEO_NUM_FRAMES, + "fps": VIDEO_FPS, + }, + ) + elif sampling_case.kind is SamplingKind.VIDEO_DIFFUSION_SINGLE: + assert len(received_sampling_params_list) == 1 + expected = DIFFUSION_SINGLE_SAMPLING_PARAMS.copy() + expected["num_outputs_per_prompt"] = expected.pop("n") # convert from n to num_outputs_per_prompt + _assert_sampling_param_values( + received_sampling_params_list[0], + { + "width": VIDEO_WIDTH, + "height": VIDEO_HEIGHT, + "num_frames": VIDEO_NUM_FRAMES, + "fps": VIDEO_FPS, + **expected, + }, + LORA_PARAMS, + ) + _assert_model_param_values(received_sampling_params_list[0], VIDEO_MODEL_PARAMS) else: raise AssertionError(f"Unknown sampling case: {sampling_case.kind}") @@ -401,9 +481,15 @@ def api_server(unused_tcp_port_factory, server_case: ServerCase, mock_async_omni @pytest.mark.parametrize( "sampling_case", [ - pytest.param(SamplingCase(kind=SamplingKind.IMAGE_NONE, sampling_params=None), id="no-sampling-params"), pytest.param( - SamplingCase(kind=SamplingKind.IMAGE_DIFFUSION_SINGLE, sampling_params=DIFFUSION_SINGLE_SAMPLING_PARAMS), + SamplingCase(kind=SamplingKind.IMAGE_NONE, sampling_params=None, lora=None), id="no-sampling-params" + ), + pytest.param( + SamplingCase( + kind=SamplingKind.IMAGE_DIFFUSION_SINGLE, + sampling_params=DIFFUSION_SINGLE_SAMPLING_PARAMS, + lora=LORA_PARAMS, + ), id="single-diffusion-sampling-params", ), ], @@ -423,7 +509,8 @@ async def test_image_generation_node(api_server: str, model: str, image_input: b kwargs["image"] = torch.zeros((1, IMAGE_WIDTH, IMAGE_HEIGHT, 3), dtype=torch.float32) if sampling_case.sampling_params is not None: kwargs["sampling_params"] = sampling_case.sampling_params - print(f"!!!!!! Calling {model} node.generate with kwargs: {sampling_case.sampling_params}") + if sampling_case.lora: + kwargs["lora"] = sampling_case.lora result = await node.generate(**kwargs) @@ -565,3 +652,75 @@ async def test_tts_nodes(api_server: str, node_cls, call_kwargs: dict, sampling_ assert result[0]["sample_rate"] == 24000 assert isinstance(result[0]["waveform"], torch.Tensor) assert result[0]["waveform"].shape == (1, 1, 24000) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "server_case,model,image_input", + [ + pytest.param( + ServerCase( + served_model="Wan-AI/Wan2.2-T2V-A14B-Diffusers", + stage_list=["diffusion"], + stage_configs=[{"stage_type": "diffusion"}], + outputs=[_build_diffusion_video_output()], + ), + "Wan-AI/Wan2.2-T2V-A14B-Diffusers", + False, + id="text-to-video", + ), + pytest.param( + ServerCase( + served_model="Wan-AI/Wan2.2-I2V-A14B-Diffusers", + stage_list=["diffusion"], + stage_configs=[{"stage_type": "diffusion"}], + outputs=[_build_diffusion_video_output()], + ), + "Wan-AI/Wan2.2-I2V-A14B-Diffusers", + True, + id="image-to-video", + ), + ], + indirect=["server_case"], +) +@pytest.mark.parametrize( + "sampling_case", + [ + pytest.param(SamplingCase(kind=SamplingKind.VIDEO_NONE, sampling_params=None), id="no-sampling-params"), + pytest.param( + SamplingCase( + kind=SamplingKind.VIDEO_DIFFUSION_SINGLE, + sampling_params=DIFFUSION_SINGLE_SAMPLING_PARAMS, + lora=LORA_PARAMS, + ), + id="single-diffusion-sampling-params", + ), + ], + indirect=["sampling_case"], +) +async def test_video_generation_node(api_server: str, model: str, image_input: bool, sampling_case: SamplingCase): + node = VLLMOmniGenerateVideo() + + kwargs = { + "url": api_server, + "model": model, + "prompt": "A beautiful sunset timelapse", + "negative_prompt": "", + "width": VIDEO_WIDTH, + "height": VIDEO_HEIGHT, + "fps": VIDEO_FPS, + "num_frames": VIDEO_NUM_FRAMES, + "model_params": VIDEO_MODEL_PARAMS, + } + if image_input: + kwargs["image"] = torch.zeros((1, VIDEO_HEIGHT, VIDEO_WIDTH, 3), dtype=torch.float32) + if sampling_case.sampling_params is not None: + kwargs["sampling_params"] = sampling_case.sampling_params + if sampling_case.lora: + kwargs["lora"] = sampling_case.lora + + result = await node.generate(**kwargs) + + assert isinstance(result, tuple) + assert len(result) == 1 + assert isinstance(result[0], VideoInput) diff --git a/tests/entrypoints/openai_api/test_image_server.py b/tests/entrypoints/openai_api/test_image_server.py index 36c58b3d8..743e36a05 100644 --- a/tests/entrypoints/openai_api/test_image_server.py +++ b/tests/entrypoints/openai_api/test_image_server.py @@ -136,7 +136,6 @@ def mock_async_diffusion(mocker: MockerFixture): async def generate(**kwargs): # Return n PIL images wrapped in result object - print("!!!!!!!!!!!!!!!!!!!!! kwargs", kwargs) n = kwargs["sampling_params_list"][0].num_outputs_per_prompt mock.captured_sampling_params_list = kwargs["sampling_params_list"] mock.captured_prompt = kwargs["prompt"] From af9784c03f9163e41f656af710e8021f781034b2 Mon Sep 17 00:00:00 2001 From: Didan Deng <33117903+wtomin@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:30:54 +0800 Subject: [PATCH 08/38] [Bugfix] Adjust Z-Image Tensor Parallelism Diff Threshold (#1808) Signed-off-by: Didan Deng <33117903+wtomin@users.noreply.github.com> --- .../test_zimage_parallelism.py | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/tests/e2e/offline_inference/test_zimage_parallelism.py b/tests/e2e/offline_inference/test_zimage_parallelism.py index 47de955aa..4ee774bfd 100644 --- a/tests/e2e/offline_inference/test_zimage_parallelism.py +++ b/tests/e2e/offline_inference/test_zimage_parallelism.py @@ -54,12 +54,13 @@ def _pil_to_float_rgb_tensor(img: Image.Image) -> torch.Tensor: def _diff_metrics(a: Image.Image, b: Image.Image) -> tuple[float, float]: - """Return (mean_abs_diff, max_abs_diff) over RGB pixels in [0, 1].""" + """Return (mean_abs_diff, p99_abs_diff) over RGB pixels in [0, 1].""" ta = _pil_to_float_rgb_tensor(a) tb = _pil_to_float_rgb_tensor(b) assert ta.shape == tb.shape, f"Image shapes differ: {ta.shape} vs {tb.shape}" abs_diff = torch.abs(ta - tb) - return abs_diff.mean().item(), abs_diff.max().item() + p99_abs_diff = torch.quantile(abs_diff.flatten(), 0.99).item() + return abs_diff.mean().item(), p99_abs_diff def _extract_single_image(outputs) -> Image.Image: @@ -196,18 +197,18 @@ def test_zimage_tensor_parallel_tp2(tmp_path: Path): assert tp1_img.width == width and tp1_img.height == height assert tp2_img.width == width and tp2_img.height == height - mean_abs_diff, max_abs_diff = _diff_metrics(tp1_img, tp2_img) + mean_abs_diff, p99_abs_diff = _diff_metrics(tp1_img, tp2_img) mean_threshold = 3e-2 - max_threshold = 5e-1 + p99_threshold = 2.5e-1 print( "Z-Image TP image diff stats (TP=1 vs TP=2): " - f"mean_abs_diff={mean_abs_diff:.6e}, max_abs_diff={max_abs_diff:.6e}; " - f"thresholds: mean<={mean_threshold:.6e}, max<={max_threshold:.6e}; " + f"mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e}; " + f"thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e}; " f"tp1_img={tp1_path}, tp2_img={tp2_path}" ) - assert mean_abs_diff <= mean_threshold and max_abs_diff <= max_threshold, ( - f"Image diff exceeded threshold: mean_abs_diff={mean_abs_diff:.6e}, max_abs_diff={max_abs_diff:.6e} " - f"(thresholds: mean<={mean_threshold:.6e}, max<={max_threshold:.6e})" + assert mean_abs_diff <= mean_threshold and p99_abs_diff <= p99_threshold, ( + f"Image diff exceeded threshold: mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e} " + f"(thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e})" ) print(f"Z-Image TP perf (lower is better): tp1_time_s={tp1_time_s:.6f}, tp2_time_s={tp2_time_s:.6f}") @@ -262,16 +263,16 @@ def test_zimage_vae_patch_parallel_tp2(tmp_path: Path): baseline_img.save(baseline_path) pp2_img.save(pp2_path) - mean_abs_diff, max_abs_diff = _diff_metrics(baseline_img, pp2_img) + mean_abs_diff, p99_abs_diff = _diff_metrics(baseline_img, pp2_img) mean_threshold = 5e-3 - max_threshold = 1e-1 + p99_threshold = 1e-1 print( "Z-Image VAE patch parallel image diff stats (TP=2, pp=1 vs pp=2): " - f"mean_abs_diff={mean_abs_diff:.6e}, max_abs_diff={max_abs_diff:.6e}; " - f"thresholds: mean<={mean_threshold:.6e}, max<={max_threshold:.6e}; " + f"mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e}; " + f"thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e}; " f"pp1_img={baseline_path}, pp2_img={pp2_path}" ) - assert mean_abs_diff <= mean_threshold and max_abs_diff <= max_threshold, ( - f"Image diff exceeded threshold: mean_abs_diff={mean_abs_diff:.6e}, max_abs_diff={max_abs_diff:.6e} " - f"(thresholds: mean<={mean_threshold:.6e}, max<={max_threshold:.6e})" + assert mean_abs_diff <= mean_threshold and p99_abs_diff <= p99_threshold, ( + f"Image diff exceeded threshold: mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e} " + f"(thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e})" ) From 0f7c9c755de30a7d212b2cc1c39227d1efe404c4 Mon Sep 17 00:00:00 2001 From: Lancer Date: Wed, 11 Mar 2026 20:52:10 +0800 Subject: [PATCH 09/38] [Bugfix] Expose base_model_paths property in _DiffusionServingModels (#1771) Signed-off-by: Lancer --- vllm_omni/entrypoints/openai/api_server.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py index 6db97dd2d..7e7b206f5 100644 --- a/vllm_omni/entrypoints/openai/api_server.py +++ b/vllm_omni/entrypoints/openai/api_server.py @@ -178,6 +178,10 @@ class _DiffusionServingModels: def __init__(self, base_model_paths: list[BaseModelPath]) -> None: self._base_model_paths = base_model_paths + @property + def base_model_paths(self) -> list[BaseModelPath]: + return self._base_model_paths + async def show_available_models(self) -> ModelList: return ModelList( data=[ From a6b55196167815f8f44901f43956942c65e7bb95 Mon Sep 17 00:00:00 2001 From: Yueqian Lin <70319226+linyueqian@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:06:20 -0400 Subject: [PATCH 10/38] [Bugfix] Report supported tasks for omni models to skip unnecessary chat init (#1645) Signed-off-by: linyueqian --- .../openai_api/test_serving_speech.py | 24 +++++++++++++++++++ vllm_omni/entrypoints/async_omni.py | 7 ++++++ vllm_omni/entrypoints/openai/api_server.py | 5 +++- 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/tests/entrypoints/openai_api/test_serving_speech.py b/tests/entrypoints/openai_api/test_serving_speech.py index 8b7177365..47790286a 100644 --- a/tests/entrypoints/openai_api/test_serving_speech.py +++ b/tests/entrypoints/openai_api/test_serving_speech.py @@ -1017,3 +1017,27 @@ class TestStreamingResponse: response = client.post("/v1/audio/speech", json={"input": "Hello", "response_format": "wav"}) assert response.status_code == 200 assert "audio/wav" in response.headers["content-type"] + + +class TestAsyncOmniSupportedTasks: + """Test that AsyncOmni reports correct supported tasks based on output modalities.""" + + @pytest.mark.asyncio + async def test_tts_only_no_generate_task(self): + """TTS-only models (audio output, no text) should not include 'generate'.""" + from vllm_omni.entrypoints.async_omni import AsyncOmni + + omni = AsyncOmni.__new__(AsyncOmni) + omni.output_modalities = [None, "audio"] + tasks = await omni.get_supported_tasks() + assert "generate" not in tasks + + @pytest.mark.asyncio + async def test_omni_model_includes_generate(self): + """Models with text output (e.g. Qwen3-Omni) should include 'generate'.""" + from vllm_omni.entrypoints.async_omni import AsyncOmni + + omni = AsyncOmni.__new__(AsyncOmni) + omni.output_modalities = ["text", None, "audio"] + tasks = await omni.get_supported_tasks() + assert "generate" in tasks diff --git a/vllm_omni/entrypoints/async_omni.py b/vllm_omni/entrypoints/async_omni.py index 3c3cb4cd6..0761ae6ef 100644 --- a/vllm_omni/entrypoints/async_omni.py +++ b/vllm_omni/entrypoints/async_omni.py @@ -140,6 +140,13 @@ class AsyncOmni(OmniBase): getattr(self, "_inline_engine", None), ) + async def get_supported_tasks(self) -> set[str]: + """Return supported tasks based on the configured stage output modalities.""" + tasks: set[str] = set() + if "text" in self.output_modalities: + tasks.add("generate") + return tasks + def _create_default_diffusion_stage_cfg(self, kwargs: dict[str, Any]) -> dict[str, Any]: """Create default diffusion stage configuration.""" # TODO: here is different from the Omni class. We should merge the two in the future. diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py index 7e7b206f5..98c9752d2 100644 --- a/vllm_omni/entrypoints/openai/api_server.py +++ b/vllm_omni/entrypoints/openai/api_server.py @@ -245,7 +245,10 @@ async def omni_run_server_worker(listen_address, sock, args, client_config=None, else: supported_tasks = ("generate",) if not supported_tasks: - supported_tasks = ("generate",) + # Only default to "generate" when get_supported_tasks is not implemented; + # TTS-only models intentionally return an empty set. + if not hasattr(engine_client, "get_supported_tasks"): + supported_tasks = ("generate",) # OMNI: Pass supported_tasks to build_app (required by upstream vLLM) app = build_openai_app(args, supported_tasks) From f144f5effe04fbb48af45614fb39b71393ea50eb Mon Sep 17 00:00:00 2001 From: Yueqian Lin <70319226+linyueqian@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:07:47 -0400 Subject: [PATCH 11/38] [Test] Add Qwen3-TTS nightly performance benchmark (#1700) Signed-off-by: linyueqian --- tests/perf/scripts/run_benchmark.py | 31 +++++--- tests/perf/stage_configs/qwen3_tts.yaml | 99 +++++++++++++++++++++++++ tests/perf/tests/test.json | 41 ++++++++++ vllm_omni/benchmarks/patch/patch.py | 67 ++++++++--------- 4 files changed, 192 insertions(+), 46 deletions(-) create mode 100644 tests/perf/stage_configs/qwen3_tts.yaml diff --git a/tests/perf/scripts/run_benchmark.py b/tests/perf/scripts/run_benchmark.py index eb2ac5359..ed2fab83e 100644 --- a/tests/perf/scripts/run_benchmark.py +++ b/tests/perf/scripts/run_benchmark.py @@ -107,8 +107,14 @@ def omni_server(request): print("OmniServer stopped") -def run_benchmark(args: list, test_name: str, flow, dataset_name: str, num_prompt) -> Any: - """Generate synthetic image with random values.""" +def run_benchmark( + args: list, + test_name: str, + flow, + dataset_name: str, + num_prompt, +) -> Any: + """Run a single benchmark iteration and return the parsed result JSON.""" current_dt = datetime.now().strftime("%Y%m%d-%H%M%S") result_filename = f"result_{test_name}_{dataset_name}_{flow}_{num_prompt}_{current_dt}.json" if "--result-filename" in args: @@ -117,10 +123,6 @@ def run_benchmark(args: list, test_name: str, flow, dataset_name: str, num_promp ["vllm", "bench", "serve", "--omni"] + args + [ - "--backend", - "openai-chat-omni", - "--endpoint", - "/v1/chat/completions", "--save-result", "--result-dir", os.environ.get("BENCHMARK_DIR", "tests"), @@ -196,7 +198,10 @@ def benchmark_params(request, omni_server): total = len(all_params) print(f"\n Running benchmark {current}/{total} for {test_name}") - return {"test_name": test_name, "params": all_params[param_index]} + return { + "test_name": test_name, + "params": all_params[param_index], + } def assert_result(result, params, num_prompt): @@ -266,7 +271,11 @@ def test_performance_benchmark(omni_server, benchmark_params): for qps, num_prompt in zip(qps_list, num_prompt_list): args = args + ["--request-rate", str(qps), "--num-prompts", str(num_prompt)] result = run_benchmark( - args=args, test_name=test_name, flow=qps, dataset_name=dataset_name, num_prompt=num_prompt + args=args, + test_name=test_name, + flow=qps, + dataset_name=dataset_name, + num_prompt=num_prompt, ) assert_result(result, params, num_prompt=num_prompt) @@ -274,6 +283,10 @@ def test_performance_benchmark(omni_server, benchmark_params): for concurrency, num_prompt in zip(max_concurrency_list, num_prompt_list): args = args + ["--max-concurrency", str(concurrency), "--num-prompts", str(num_prompt), "--request-rate", "inf"] result = run_benchmark( - args=args, test_name=test_name, flow=concurrency, dataset_name=dataset_name, num_prompt=num_prompt + args=args, + test_name=test_name, + flow=concurrency, + dataset_name=dataset_name, + num_prompt=num_prompt, ) assert_result(result, params, num_prompt=num_prompt) diff --git a/tests/perf/stage_configs/qwen3_tts.yaml b/tests/perf/stage_configs/qwen3_tts.yaml new file mode 100644 index 000000000..4ba4e6e83 --- /dev/null +++ b/tests/perf/stage_configs/qwen3_tts.yaml @@ -0,0 +1,99 @@ +# Stage config for running Qwen3-TTS with 2-stage architecture +# Stage 0: Talker (text -> 8-layer RVQ codec codes) +# Stage 1: Code2Wav (codec codes -> audio waveform) +# +# The following config has been verified on 1x H100-80G GPU. +async_chunk: true +stage_args: + - stage_id: 0 + stage_type: llm + runtime: + devices: "0" + max_batch_size: 4 + engine_args: + model_stage: qwen3_tts + model_arch: Qwen3TTSTalkerForConditionalGeneration + hf_overrides: + architectures: [Qwen3TTSTalkerForConditionalGeneration] + worker_type: ar + scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler + enforce_eager: false + trust_remote_code: true + async_scheduling: false + enable_prefix_caching: false + engine_output_type: latent + gpu_memory_utilization: 0.3 + distributed_executor_backend: "mp" + max_num_batched_tokens: 512 + max_model_len: 4096 + custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_tts.talker2code2wav_async_chunk + output_connectors: + to_stage_1: connector_of_shared_memory + default_sampling_params: + temperature: 0.9 + top_k: 50 + max_tokens: 4096 + seed: 42 + detokenize: false + repetition_penalty: 1.05 + stop_token_ids: [2150] + + - stage_id: 1 + stage_type: llm + runtime: + devices: "0" + max_batch_size: 4 + engine_args: + model_stage: code2wav + model_arch: Qwen3TTSCode2Wav + hf_overrides: + architectures: [Qwen3TTSCode2Wav] + worker_type: generation + scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler + enforce_eager: true + trust_remote_code: true + async_scheduling: false + enable_prefix_caching: false + engine_output_type: audio + gpu_memory_utilization: 0.2 + distributed_executor_backend: "mp" + max_num_batched_tokens: 8192 + max_model_len: 32768 + engine_input_source: [0] + final_output: true + final_output_type: audio + input_connectors: + from_stage_0: connector_of_shared_memory + tts_args: + max_instructions_length: 500 + default_sampling_params: + temperature: 0.0 + top_p: 1.0 + top_k: -1 + max_tokens: 65536 + seed: 42 + detokenize: true + repetition_penalty: 1.0 + +runtime: + enabled: true + defaults: + window_size: -1 + max_inflight: 4 + + connectors: + connector_of_shared_memory: + name: SharedMemoryConnector + extra: + shm_threshold_bytes: 65536 + codec_streaming: true + connector_get_sleep_s: 0.01 + connector_get_max_wait_first_chunk: 3000 + connector_get_max_wait: 300 + codec_chunk_frames: 25 + codec_left_context_frames: 25 + + edges: + - from: 0 + to: 1 + window_size: -1 diff --git a/tests/perf/tests/test.json b/tests/perf/tests/test.json index 88041c119..65ef6588d 100644 --- a/tests/perf/tests/test.json +++ b/tests/perf/tests/test.json @@ -8,6 +8,8 @@ "benchmark_params": [ { "dataset_name": "random", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", "num_prompts": [ 10, 40 @@ -28,6 +30,8 @@ }, { "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", "num_prompts": [ 10, 40 @@ -88,6 +92,8 @@ "benchmark_params": [ { "dataset_name": "random", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", "num_prompts": [ 10, 40 @@ -108,6 +114,8 @@ }, { "dataset_name": "random-mm", + "backend": "openai-chat-omni", + "endpoint": "/v1/chat/completions", "num_prompts": [ 10, 40 @@ -140,5 +148,38 @@ } } ] + }, + { + "test_name": "test_qwen3_tts", + "server_params": { + "model": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice", + "stage_config_name": "qwen3_tts.yaml" + }, + "benchmark_params": [ + { + "dataset_name": "random", + "backend": "openai-audio-speech", + "endpoint": "/v1/audio/speech", + "num_prompts": [ + 10, + 40 + ], + "max_concurrency": [ + 1, + 4 + ], + "random_input_len": 100, + "random_output_len": 100, + "extra_body": { + "voice": "Vivian", + "language": "English" + }, + "percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration", + "baseline": { + "mean_audio_ttfp_ms": 100000, + "mean_audio_rtf": 100000 + } + } + ] } ] diff --git a/vllm_omni/benchmarks/patch/patch.py b/vllm_omni/benchmarks/patch/patch.py index 78792e81c..84bcfa93a 100644 --- a/vllm_omni/benchmarks/patch/patch.py +++ b/vllm_omni/benchmarks/patch/patch.py @@ -208,10 +208,11 @@ async def async_request_openai_chat_omni_completions( async def async_request_openai_audio_speech( request_func_input: RequestFuncInput, session: aiohttp.ClientSession, pbar: tqdm | None = None ) -> MixRequestFuncOutput: - """Non-streaming request to /v1/audio/speech endpoint. + """Streaming request to /v1/audio/speech endpoint. - The endpoint returns raw audio bytes (e.g. WAV). Pass voice, instructions, - and other TTS-specific fields via ``extra_body``. + Sends ``stream=true`` with ``response_format=pcm`` so the server returns + raw PCM chunks as they are decoded. This allows measuring TTFP (time to + first audio packet) separately from E2EL. """ api_url = request_func_input.api_url _validate_api_url(api_url, "OpenAI Audio Speech API", "audio/speech") @@ -219,16 +220,11 @@ async def async_request_openai_audio_speech( payload = { "model": request_func_input.model_name if request_func_input.model_name else request_func_input.model, "input": request_func_input.prompt, + "stream": True, + "response_format": "pcm", } _update_payload_common(payload, request_func_input) - response_format = payload.get("response_format", "wav") - if response_format == "pcm": - raise ValueError( - "pcm response format is not supported yet. \ - Please use other formats like wav, mp3, etc. instead." - ) - headers = { "Content-Type": "application/json", "Authorization": f"Bearer {os.environ.get('OPENAI_API_KEY')}", @@ -238,41 +234,38 @@ async def async_request_openai_audio_speech( output = MixRequestFuncOutput() output.prompt_len = request_func_input.prompt_len + # PCM format: 16-bit signed, 24 kHz, mono + sample_rate = 24000 + sample_width = 2 # 16-bit = 2 bytes + channels = 1 + st = time.perf_counter() output.start_time = st + total_pcm_bytes = 0 try: async with session.post(url=api_url, json=payload, headers=headers) as response: if response.status == 200: - audio_bytes = await response.read() + async for chunk in response.content.iter_any(): + if not chunk: + continue + timestamp = time.perf_counter() + if output.audio_ttfp == 0.0: + output.audio_ttfp = timestamp - st + output.ttft = output.audio_ttfp + total_pcm_bytes += len(chunk) + end_time = time.perf_counter() output.latency = end_time - st - # ttft = latency since this is a non-streaming request - # hence there is no distinction between first and last token/audio - output.ttft = output.latency - output.audio_ttfp = output.latency - try: - audio_segment = AudioSegment.from_file(io.BytesIO(audio_bytes)) - output.audio_duration = len(audio_segment) / 1000.0 - frame_width = audio_segment.frame_width - if frame_width > 0: - output.audio_frames = len(audio_segment.raw_data) // frame_width - else: - output.audio_frames = 0 - logger.warning("Audio frame width is zero") - if output.audio_duration > 0: - # rtf = audio_generate_time / audio_duration and - # audio_generate_time = latency since this is a non-streaming request - # so the time to receive last portion of audio is the latency - output.audio_rtf = output.latency / output.audio_duration - else: - output.audio_rtf = 0 - logger.warning("Audio duration is zero") - output.success = True - except Exception as e: - output.success = False - output.error = f"Failed to parse audio response: {e}" - logger.error(f"ERROR: Failed to parse audio response: {e}") + total_samples = total_pcm_bytes // (sample_width * channels) + output.audio_duration = total_samples / sample_rate + output.audio_frames = total_samples + if output.audio_duration > 0: + output.audio_rtf = output.latency / output.audio_duration + else: + output.audio_rtf = 0 + logger.warning("Audio duration is zero") + output.success = True else: output.error = response.reason or "" output.success = False From acde431668f74e0b4c28bef0f4a67d21feff376a Mon Sep 17 00:00:00 2001 From: Yueqian Lin <70319226+linyueqian@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:09:26 -0400 Subject: [PATCH 12/38] Add Qwen3-TTS benchmark scripts (#1573) Signed-off-by: linyueqian --- benchmarks/qwen3-tts/README.md | 100 +++++ benchmarks/qwen3-tts/plot_results.py | 254 +++++++++++++ benchmarks/qwen3-tts/results/.gitignore | 3 + benchmarks/qwen3-tts/run_benchmark.sh | 272 ++++++++++++++ .../qwen3-tts/transformers/bench_tts_hf.py | 281 ++++++++++++++ .../qwen3-tts/vllm_omni/bench_tts_serve.py | 348 ++++++++++++++++++ .../vllm_omni/configs/qwen3_tts_bs1.yaml | 96 +++++ .../vllm_omni/configs/qwen3_tts_bs4.yaml | 97 +++++ 8 files changed, 1451 insertions(+) create mode 100644 benchmarks/qwen3-tts/README.md create mode 100644 benchmarks/qwen3-tts/plot_results.py create mode 100644 benchmarks/qwen3-tts/results/.gitignore create mode 100755 benchmarks/qwen3-tts/run_benchmark.sh create mode 100644 benchmarks/qwen3-tts/transformers/bench_tts_hf.py create mode 100644 benchmarks/qwen3-tts/vllm_omni/bench_tts_serve.py create mode 100644 benchmarks/qwen3-tts/vllm_omni/configs/qwen3_tts_bs1.yaml create mode 100644 benchmarks/qwen3-tts/vllm_omni/configs/qwen3_tts_bs4.yaml diff --git a/benchmarks/qwen3-tts/README.md b/benchmarks/qwen3-tts/README.md new file mode 100644 index 000000000..b038669e8 --- /dev/null +++ b/benchmarks/qwen3-tts/README.md @@ -0,0 +1,100 @@ +# Qwen3-TTS Benchmark + +Benchmarks for Qwen3-TTS text-to-speech models, comparing vLLM-Omni streaming serving against HuggingFace Transformers offline inference. + +## Prerequisites + +```bash +pip install matplotlib aiohttp soundfile numpy tqdm +pip install qwen_tts # for HF baseline +``` + +## Quick Start + +Run the full benchmark (vllm-omni + HF baseline) with a single command: + +```bash +cd benchmarks/qwen3-tts +bash run_benchmark.sh +``` + +Results (JSON + PNG plots) are saved to `results/`. + +### Common options + +```bash +# Only vllm-omni (skip HF baseline) +bash run_benchmark.sh --async-only + +# Only HF baseline +bash run_benchmark.sh --hf-only + +# Use a different model (e.g. 1.7B) +MODEL=Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice bash run_benchmark.sh --async-only + +# Use batch_size=4 config for higher throughput +STAGE_CONFIG=vllm_omni/configs/qwen3_tts_bs4.yaml bash run_benchmark.sh --async-only + +# Custom GPU, prompt count, concurrency levels +GPU_DEVICE=1 NUM_PROMPTS=20 CONCURRENCY="1 4" bash run_benchmark.sh +``` + +## Manual Steps + +### 1) Start the vLLM-Omni server + +```bash +CUDA_VISIBLE_DEVICES=0 python -m vllm_omni.entrypoints.cli.main serve \ + "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice" \ + --omni --host 127.0.0.1 --port 8000 \ + --stage-configs-path benchmarks/qwen3-tts/vllm_omni/configs/qwen3_tts_bs1.yaml \ + --trust-remote-code +``` + +### 2) Run online serving benchmark + +```bash +python vllm_omni/bench_tts_serve.py \ + --port 8000 \ + --num-prompts 50 \ + --max-concurrency 1 4 10 \ + --config-name "async_chunk" \ + --result-dir results/ +``` + +### 3) Run HuggingFace baseline + +```bash +python transformers/bench_tts_hf.py \ + --model "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice" \ + --num-prompts 50 \ + --gpu-device 0 \ + --result-dir results/ +``` + +### 4) Generate comparison plots + +```bash +python plot_results.py \ + --results results/bench_async_chunk_*.json results/bench_hf_transformers_*.json \ + --labels "vllm-omni" "hf_transformers" \ + --output results/comparison.png +``` + +## Stage Configs + +| Config | Batch Size | Description | +|--------|:----------:|-------------| +| `vllm_omni/configs/qwen3_tts_bs1.yaml` | 1 | Single-request processing (lower latency) | +| `vllm_omni/configs/qwen3_tts_bs4.yaml` | 4 | Concurrent request processing (higher throughput) | + +Both configs use a 2-stage pipeline (Talker -> Code2Wav) with `async_chunk` streaming enabled. The `SharedMemoryConnector` streams codec frames (25-frame chunks with 25-frame context overlap) between stages. + +The model is specified via the CLI `--model` flag (or `MODEL` env var in `run_benchmark.sh`), so the same configs work for both the 0.6B and 1.7B model variants. + +## Metrics + +- **TTFP (Time to First Audio Packet)**: Time from request to first audio chunk (streaming latency) +- **E2E (End-to-End Latency)**: Total time from request to complete audio response +- **RTF (Real-Time Factor)**: E2E latency / audio duration. RTF < 1.0 means faster-than-real-time synthesis +- **Throughput**: Total audio seconds generated per wall-clock second diff --git a/benchmarks/qwen3-tts/plot_results.py b/benchmarks/qwen3-tts/plot_results.py new file mode 100644 index 000000000..e750101e3 --- /dev/null +++ b/benchmarks/qwen3-tts/plot_results.py @@ -0,0 +1,254 @@ +"""Plot Qwen3-TTS benchmark results. + +Generates comparison bar charts similar to the async_chunk design doc: +- TTFP (Time-to-First-Packet) across concurrency levels +- E2E latency across concurrency levels +- RTF (Real-Time Factor) across concurrency levels + +Usage: + # Compare two configs (async_chunk vs no_async_chunk): + python plot_results.py \ + --results results/bench_async_chunk_*.json results/bench_no_async_chunk_*.json \ + --labels "async_chunk" "no_async_chunk" \ + --output results/qwen3_tts_benchmark.png + + # Single config: + python plot_results.py \ + --results results/bench_async_chunk_*.json \ + --labels "async_chunk" \ + --output results/qwen3_tts_benchmark.png +""" + +import argparse +import json +from pathlib import Path + +import matplotlib.pyplot as plt +import numpy as np + + +def load_results(result_files: list[str]) -> list[list[dict]]: + """Load benchmark results from JSON files.""" + all_results = [] + for f in result_files: + with open(f) as fh: + data = json.load(fh) + all_results.append(data) + return all_results + + +def plot_comparison( + all_results: list[list[dict]], + labels: list[str], + output_path: str, + title_prefix: str = "Qwen3-TTS", +): + """Generate comparison bar charts.""" + n_configs = len(all_results) + + # Collect concurrency levels present in ALL configs (skip missing data) + all_concurrencies = [set(r["concurrency"] for r in results) for results in all_results] + concurrencies = sorted(set.union(*all_concurrencies)) + + # Build data arrays, using None for missing concurrency levels + ttfp_data = {label: [] for label in labels} + e2e_data = {label: [] for label in labels} + rtf_data = {label: [] for label in labels} + throughput_data = {label: [] for label in labels} + + for results, label in zip(all_results, labels): + conc_map = {r["concurrency"]: r for r in results} + for c in concurrencies: + r = conc_map.get(c) + ttfp_data[label].append(r["mean_ttfp_ms"] if r else None) + e2e_data[label].append(r["mean_e2e_ms"] if r else None) + rtf_data[label].append(r["mean_rtf"] if r else None) + throughput_data[label].append(r["audio_throughput"] if r else None) + + fig, axes = plt.subplots(2, 2, figsize=(14, 10)) + fig.suptitle(f"{title_prefix} Performance Benchmark", fontsize=16, fontweight="bold") + + x = np.arange(len(concurrencies)) + width = 0.35 if n_configs == 2 else 0.5 + if n_configs > 1: + offsets = np.linspace(-width / 2 * (n_configs - 1), width / 2 * (n_configs - 1), n_configs) + else: + offsets = [0] + + colors = ["#2196F3", "#FF5722", "#4CAF50", "#FFC107"] + + def plot_metric(ax, data_dict, ylabel, title, fmt=".1f"): + bars = [] + for i, (label, values) in enumerate(data_dict.items()): + # Replace None with 0 for plotting, but track which are missing + plot_values = [v if v is not None else 0 for v in values] + color = colors[i % len(colors)] + bar = ax.bar(x + offsets[i], plot_values, width, label=label, color=color, alpha=0.85) + bars.append(bar) + # Add value labels on bars (skip None/missing data) + max_val = max((v for v in values if v is not None), default=1) + for rect, val in zip(bar, values): + if val is not None and val > 0: + ax.text( + rect.get_x() + rect.get_width() / 2, + rect.get_height() + max_val * 0.02, + f"{val:{fmt}}", + ha="center", + va="bottom", + fontsize=9, + fontweight="bold", + ) + ax.set_xlabel("Concurrency", fontsize=12) + ax.set_ylabel(ylabel, fontsize=12) + ax.set_title(title, fontsize=13, fontweight="bold") + ax.set_xticks(x) + ax.set_xticklabels([str(c) for c in concurrencies]) + ax.legend(fontsize=10) + ax.grid(axis="y", alpha=0.3) + ax.set_axisbelow(True) + + plot_metric(axes[0, 0], ttfp_data, "TTFP (ms)", "Time to First Audio Packet (TTFP)") + plot_metric(axes[0, 1], e2e_data, "E2E Latency (ms)", "End-to-End Latency (E2E)") + plot_metric(axes[1, 0], rtf_data, "RTF", "Real-Time Factor (RTF)", fmt=".3f") + plot_metric(axes[1, 1], throughput_data, "Audio-sec / Wall-sec", "Audio Throughput", fmt=".2f") + + plt.tight_layout() + plt.savefig(output_path, dpi=150, bbox_inches="tight") + print(f"Plot saved to {output_path}") + plt.close() + + +def plot_single_summary(results: list[dict], label: str, output_path: str): + """Generate a single-config summary with percentile breakdown.""" + concurrencies = [r["concurrency"] for r in results] + + fig, axes = plt.subplots(1, 3, figsize=(16, 5)) + fig.suptitle(f"Qwen3-TTS Benchmark - {label}", fontsize=15, fontweight="bold") + + # TTFP breakdown + ax = axes[0] + means = [r["mean_ttfp_ms"] for r in results] + medians = [r["median_ttfp_ms"] for r in results] + p90s = [r["p90_ttfp_ms"] for r in results] + p99s = [r["p99_ttfp_ms"] for r in results] + x = np.arange(len(concurrencies)) + w = 0.2 + ax.bar(x - 1.5 * w, means, w, label="mean", color="#2196F3") + ax.bar(x - 0.5 * w, medians, w, label="median", color="#4CAF50") + ax.bar(x + 0.5 * w, p90s, w, label="p90", color="#FF9800") + ax.bar(x + 1.5 * w, p99s, w, label="p99", color="#F44336") + ax.set_xticks(x) + ax.set_xticklabels([str(c) for c in concurrencies]) + ax.set_xlabel("Concurrency") + ax.set_ylabel("TTFP (ms)") + ax.set_title("Time to First Audio Packet") + ax.legend(fontsize=9) + ax.grid(axis="y", alpha=0.3) + + # E2E breakdown + ax = axes[1] + means = [r["mean_e2e_ms"] for r in results] + medians = [r["median_e2e_ms"] for r in results] + p90s = [r["p90_e2e_ms"] for r in results] + p99s = [r["p99_e2e_ms"] for r in results] + ax.bar(x - 1.5 * w, means, w, label="mean", color="#2196F3") + ax.bar(x - 0.5 * w, medians, w, label="median", color="#4CAF50") + ax.bar(x + 0.5 * w, p90s, w, label="p90", color="#FF9800") + ax.bar(x + 1.5 * w, p99s, w, label="p99", color="#F44336") + ax.set_xticks(x) + ax.set_xticklabels([str(c) for c in concurrencies]) + ax.set_xlabel("Concurrency") + ax.set_ylabel("E2E Latency (ms)") + ax.set_title("End-to-End Latency") + ax.legend(fontsize=9) + ax.grid(axis="y", alpha=0.3) + + # RTF + ax = axes[2] + means = [r["mean_rtf"] for r in results] + medians = [r["median_rtf"] for r in results] + ax.bar(x - 0.15, means, 0.3, label="mean", color="#2196F3") + ax.bar(x + 0.15, medians, 0.3, label="median", color="#4CAF50") + ax.set_xticks(x) + ax.set_xticklabels([str(c) for c in concurrencies]) + ax.set_xlabel("Concurrency") + ax.set_ylabel("RTF") + ax.set_title("Real-Time Factor") + ax.legend(fontsize=9) + ax.grid(axis="y", alpha=0.3) + + plt.tight_layout() + plt.savefig(output_path, dpi=150, bbox_inches="tight") + print(f"Plot saved to {output_path}") + plt.close() + + +def print_comparison_table(all_results: list[list[dict]], labels: list[str]): + """Print a markdown-formatted comparison table.""" + concurrencies = sorted(set(r["concurrency"] for r in all_results[0])) + + print("\n## Benchmark Results\n") + header = "| Metric | Concurrency |" + sep = "| --- | --- |" + for label in labels: + header += f" {label} |" + sep += " --- |" + print(header) + print(sep) + + for metric, key, fmt in [ + ("TTFP (ms)", "mean_ttfp_ms", ".1f"), + ("E2E (ms)", "mean_e2e_ms", ".1f"), + ("RTF", "mean_rtf", ".3f"), + ("Throughput (audio-s/s)", "audio_throughput", ".2f"), + ]: + for c in concurrencies: + row = f"| {metric} | {c} |" + for results in all_results: + conc_map = {r["concurrency"]: r for r in results} + val = conc_map.get(c, {}).get(key, 0) + row += f" {val:{fmt}} |" + print(row) + + # Improvement calculation (only if 2 configs) + if len(all_results) == 2: + print(f"\n## Improvement ({labels[0]} vs {labels[1]})\n") + print("| Metric | Concurrency | Improvement |") + print("| --- | --- | --- |") + for metric, key in [("TTFP", "mean_ttfp_ms"), ("E2E", "mean_e2e_ms"), ("RTF", "mean_rtf")]: + for c in concurrencies: + m0 = {r["concurrency"]: r for r in all_results[0]} + m1 = {r["concurrency"]: r for r in all_results[1]} + v0 = m0.get(c, {}).get(key, 0) + v1 = m1.get(c, {}).get(key, 0) + if v1 > 0: + pct = (v1 - v0) / v1 * 100 + print(f"| {metric} | {c} | {pct:+.1f}% |") + + +def parse_args(): + parser = argparse.ArgumentParser(description="Plot Qwen3-TTS benchmark results") + parser.add_argument( + "--results", type=str, nargs="+", required=True, help="Path(s) to result JSON files (one per config)" + ) + parser.add_argument( + "--labels", type=str, nargs="+", required=True, help="Labels for each config (must match --results count)" + ) + parser.add_argument("--output", type=str, default="results/qwen3_tts_benchmark.png", help="Output image path") + parser.add_argument("--title", type=str, default="Qwen3-TTS", help="Title prefix for the plot") + return parser.parse_args() + + +if __name__ == "__main__": + args = parse_args() + assert len(args.results) == len(args.labels), "--results and --labels must have the same count" + + all_results = load_results(args.results) + print_comparison_table(all_results, args.labels) + + Path(args.output).parent.mkdir(parents=True, exist_ok=True) + + if len(all_results) == 1: + plot_single_summary(all_results[0], args.labels[0], args.output) + else: + plot_comparison(all_results, args.labels, args.output, title_prefix=args.title) diff --git a/benchmarks/qwen3-tts/results/.gitignore b/benchmarks/qwen3-tts/results/.gitignore new file mode 100644 index 000000000..5b6759ef7 --- /dev/null +++ b/benchmarks/qwen3-tts/results/.gitignore @@ -0,0 +1,3 @@ +# Benchmark results are machine-specific - do not commit +* +!.gitignore diff --git a/benchmarks/qwen3-tts/run_benchmark.sh b/benchmarks/qwen3-tts/run_benchmark.sh new file mode 100755 index 000000000..ef85d64d6 --- /dev/null +++ b/benchmarks/qwen3-tts/run_benchmark.sh @@ -0,0 +1,272 @@ +#!/bin/bash +# Qwen3-TTS Benchmark Runner +# +# Compares vllm-omni streaming serving vs HuggingFace transformers offline inference. +# Produces JSON results and comparison plots. +# +# Usage: +# # Full comparison (vllm-omni + HF): +# bash run_benchmark.sh +# +# # Only vllm-omni async_chunk config: +# bash run_benchmark.sh --async-only +# +# # Only HuggingFace baseline: +# bash run_benchmark.sh --hf-only +# +# # vllm-omni only (skip HF): +# bash run_benchmark.sh --skip-hf +# +# # Custom settings: +# GPU_DEVICE=1 NUM_PROMPTS=20 CONCURRENCY="1 4" bash run_benchmark.sh +# +# # Use 1.7B model: +# MODEL=Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice bash run_benchmark.sh --async-only +# +# # Use batch_size=4 config: +# STAGE_CONFIG=vllm_omni/configs/qwen3_tts_bs4.yaml bash run_benchmark.sh --async-only +# +# Environment variables: +# GPU_DEVICE - GPU index to use (default: 0) +# NUM_PROMPTS - Number of prompts per concurrency level (default: 50) +# CONCURRENCY - Space-separated concurrency levels (default: "1 4 10") +# MODEL - Model name (default: Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice) +# PORT - Server port (default: 8000) +# GPU_MEM_TALKER - gpu_memory_utilization for talker stage (default: 0.3) +# GPU_MEM_CODE2WAV - gpu_memory_utilization for code2wav stage (default: 0.2) +# STAGE_CONFIG - Path to stage config YAML (default: configs/qwen3_tts_bs1.yaml) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +# Defaults +GPU_DEVICE="${GPU_DEVICE:-0}" +NUM_PROMPTS="${NUM_PROMPTS:-50}" +CONCURRENCY="${CONCURRENCY:-1 4 10}" +MODEL="${MODEL:-Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice}" +PORT="${PORT:-8000}" +GPU_MEM_TALKER="${GPU_MEM_TALKER:-0.3}" +GPU_MEM_CODE2WAV="${GPU_MEM_CODE2WAV:-0.2}" +NUM_WARMUPS="${NUM_WARMUPS:-3}" +STAGE_CONFIG="${STAGE_CONFIG:-vllm_omni/configs/qwen3_tts_bs1.yaml}" +RESULT_DIR="${SCRIPT_DIR}/results" +TIMESTAMP="$(date +%Y%m%d_%H%M%S)" + +# Parse args +RUN_ASYNC=true +RUN_HF=true +for arg in "$@"; do + case "$arg" in + --async-only) RUN_HF=false ;; + --hf-only) RUN_ASYNC=false ;; + --skip-hf) RUN_HF=false ;; + esac +done + +mkdir -p "${RESULT_DIR}" + +echo "============================================================" +echo " Qwen3-TTS Benchmark" +echo "============================================================" +echo " GPU: ${GPU_DEVICE}" +echo " Model: ${MODEL}" +echo " Prompts: ${NUM_PROMPTS}" +echo " Concurrency: ${CONCURRENCY}" +echo " Port: ${PORT}" +echo " Stage config: ${STAGE_CONFIG}" +echo " Results: ${RESULT_DIR}" +echo "============================================================" + +# Prepare stage config with correct GPU device and memory settings +prepare_config() { + local config_template="$1" + local config_name="$2" + local output_path="${RESULT_DIR}/${config_name}_stage_config.yaml" + + # Use sed to patch GPU device and memory utilization + sed \ + -e "s/devices: \"0\"/devices: \"${GPU_DEVICE}\"/g" \ + -e "s/gpu_memory_utilization: 0.3/gpu_memory_utilization: ${GPU_MEM_TALKER}/g" \ + -e "s/gpu_memory_utilization: 0.2/gpu_memory_utilization: ${GPU_MEM_CODE2WAV}/g" \ + "${config_template}" > "${output_path}" + + echo "${output_path}" +} + +# Start server and wait for it to be ready +start_server() { + local stage_config="$1" + local config_name="$2" + local log_file="${RESULT_DIR}/server_${config_name}_${TIMESTAMP}.log" + + echo "" + echo "Starting server with config: ${config_name}" + echo " Stage config: ${stage_config}" + echo " Log file: ${log_file}" + + VLLM_WORKER_MULTIPROC_METHOD=spawn \ + CUDA_VISIBLE_DEVICES="${GPU_DEVICE}" \ + python -m vllm_omni.entrypoints.cli.main serve "${MODEL}" \ + --omni \ + --host 127.0.0.1 \ + --port "${PORT}" \ + --stage-configs-path "${stage_config}" \ + --stage-init-timeout 120 \ + --trust-remote-code \ + --disable-log-stats \ + > "${log_file}" 2>&1 & + + SERVER_PID=$! + echo " Server PID: ${SERVER_PID}" + + # Wait for server to be ready + echo " Waiting for server to be ready..." + local max_wait=300 + local waited=0 + while [ ${waited} -lt ${max_wait} ]; do + if curl -sf "http://127.0.0.1:${PORT}/v1/models" > /dev/null 2>&1; then + echo " Server is ready! (waited ${waited}s)" + return 0 + fi + # Check if process is still alive + if ! kill -0 ${SERVER_PID} 2>/dev/null; then + echo " ERROR: Server process died. Check log: ${log_file}" + tail -20 "${log_file}" + return 1 + fi + sleep 2 + waited=$((waited + 2)) + done + + echo " ERROR: Server did not start within ${max_wait}s. Check log: ${log_file}" + kill ${SERVER_PID} 2>/dev/null || true + return 1 +} + +# Stop the server +stop_server() { + if [ -n "${SERVER_PID:-}" ]; then + echo " Stopping server (PID: ${SERVER_PID})..." + kill ${SERVER_PID} 2>/dev/null || true + wait ${SERVER_PID} 2>/dev/null || true + # Kill any remaining child processes on the port + local pids + pids=$(lsof -ti:${PORT} 2>/dev/null || true) + if [ -n "${pids}" ]; then + echo " Cleaning up remaining processes on port ${PORT}..." + echo "${pids}" | xargs kill -9 2>/dev/null || true + fi + echo " Server stopped." + SERVER_PID="" + fi +} + +# Cleanup on exit +trap 'stop_server' EXIT + +# Run benchmark for a given config +run_bench() { + local config_name="$1" + local config_template="$2" + + echo "" + echo "============================================================" + echo " Benchmarking: ${config_name}" + echo "============================================================" + + local stage_config + stage_config=$(prepare_config "${config_template}" "${config_name}") + + start_server "${stage_config}" "${config_name}" + + # Convert concurrency string to args + local conc_args="" + for c in ${CONCURRENCY}; do + conc_args="${conc_args} ${c}" + done + + cd "${PROJECT_ROOT}" + python "${SCRIPT_DIR}/vllm_omni/bench_tts_serve.py" \ + --host 127.0.0.1 \ + --port "${PORT}" \ + --num-prompts "${NUM_PROMPTS}" \ + --max-concurrency ${conc_args} \ + --num-warmups "${NUM_WARMUPS}" \ + --config-name "${config_name}" \ + --result-dir "${RESULT_DIR}" + + stop_server + + # Allow GPU memory to settle + sleep 5 +} + +# Run vllm-omni benchmark +if [ "${RUN_ASYNC}" = true ]; then + run_bench "async_chunk" "${SCRIPT_DIR}/${STAGE_CONFIG}" +fi + +# Run HuggingFace baseline benchmark +if [ "${RUN_HF}" = true ]; then + echo "" + echo "============================================================" + echo " Benchmarking: HuggingFace transformers (offline)" + echo "============================================================" + + cd "${PROJECT_ROOT}" + python "${SCRIPT_DIR}/transformers/bench_tts_hf.py" \ + --model "${MODEL}" \ + --num-prompts "${NUM_PROMPTS}" \ + --num-warmups "${NUM_WARMUPS}" \ + --gpu-device "${GPU_DEVICE}" \ + --config-name "hf_transformers" \ + --result-dir "${RESULT_DIR}" + + # Allow GPU memory to settle + sleep 5 +fi + +# Plot results +echo "" +echo "============================================================" +echo " Generating plots..." +echo "============================================================" + +RESULT_FILES="" +LABELS="" + +if [ "${RUN_ASYNC}" = true ]; then + ASYNC_FILE=$(ls -t "${RESULT_DIR}"/bench_async_chunk_*.json 2>/dev/null | head -1) + if [ -n "${ASYNC_FILE}" ]; then + RESULT_FILES="${ASYNC_FILE}" + LABELS="async_chunk" + fi +fi + +if [ "${RUN_HF}" = true ]; then + HF_FILE=$(ls -t "${RESULT_DIR}"/bench_hf_transformers_*.json 2>/dev/null | head -1) + if [ -n "${HF_FILE}" ]; then + if [ -n "${RESULT_FILES}" ]; then + RESULT_FILES="${RESULT_FILES} ${HF_FILE}" + LABELS="${LABELS} hf_transformers" + else + RESULT_FILES="${HF_FILE}" + LABELS="hf_transformers" + fi + fi +fi + +if [ -n "${RESULT_FILES}" ]; then + python "${SCRIPT_DIR}/plot_results.py" \ + --results ${RESULT_FILES} \ + --labels ${LABELS} \ + --output "${RESULT_DIR}/qwen3_tts_benchmark_${TIMESTAMP}.png" +fi + +echo "" +echo "============================================================" +echo " Benchmark complete!" +echo " Results: ${RESULT_DIR}" +echo "============================================================" diff --git a/benchmarks/qwen3-tts/transformers/bench_tts_hf.py b/benchmarks/qwen3-tts/transformers/bench_tts_hf.py new file mode 100644 index 000000000..0e0ef8e9e --- /dev/null +++ b/benchmarks/qwen3-tts/transformers/bench_tts_hf.py @@ -0,0 +1,281 @@ +"""Benchmark Qwen3-TTS using HuggingFace transformers (qwen_tts library). + +Measures E2E latency, RTF, and audio duration for offline (non-serving) inference. +Results are saved in the same JSON format as bench_tts_serve.py for unified plotting. + +Usage: + python bench_tts_hf.py \ + --model Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice \ + --num-prompts 50 \ + --num-warmups 3 \ + --gpu-device 0 \ + --result-dir results/ +""" + +import argparse +import json +import time +from dataclasses import asdict, dataclass, field +from datetime import datetime +from pathlib import Path + +import numpy as np +import soundfile as sf +import torch + +PROMPTS = [ + "Hello, welcome to the voice synthesis benchmark test.", + "She said she would be here by noon, but nobody showed up.", + "The quick brown fox jumps over the lazy dog near the riverbank.", + "I can't believe how beautiful the sunset looks from up here on the mountain.", + "Please remember to bring your identification documents to the appointment tomorrow morning.", + "Have you ever wondered what it would be like to travel through time and visit ancient civilizations?", + "The restaurant on the corner serves the best pasta I have ever tasted in my entire life.", + "After the meeting, we should discuss the quarterly results and plan for the next phase.", + "Learning a new language takes patience, practice, and a genuine curiosity about other cultures.", + "The train leaves at half past seven, so we need to arrive at the station before then.", + "Could you please turn down the music a little bit, I'm trying to concentrate on my work.", + "It was a dark and stormy night when the old lighthouse keeper heard a knock at the door.", +] + + +@dataclass +class BenchmarkResult: + config_name: str = "" + concurrency: int = 1 # always 1 for offline + num_prompts: int = 0 + completed: int = 0 + failed: int = 0 + duration_s: float = 0.0 + # TTFP stats - not applicable for HF offline, set to E2E for compatibility + mean_ttfp_ms: float = 0.0 + median_ttfp_ms: float = 0.0 + std_ttfp_ms: float = 0.0 + p90_ttfp_ms: float = 0.0 + p95_ttfp_ms: float = 0.0 + p99_ttfp_ms: float = 0.0 + # E2E stats (ms) + mean_e2e_ms: float = 0.0 + median_e2e_ms: float = 0.0 + std_e2e_ms: float = 0.0 + p90_e2e_ms: float = 0.0 + p95_e2e_ms: float = 0.0 + p99_e2e_ms: float = 0.0 + # RTF stats + mean_rtf: float = 0.0 + median_rtf: float = 0.0 + std_rtf: float = 0.0 + p99_rtf: float = 0.0 + # Audio stats + mean_audio_duration_s: float = 0.0 + total_audio_duration_s: float = 0.0 + audio_throughput: float = 0.0 + request_throughput: float = 0.0 + # Per-request details + per_request: list = field(default_factory=list) + + +def run_benchmark(args): + from qwen_tts import Qwen3TTSModel + + device = f"cuda:{args.gpu_device}" + print(f"Loading model: {args.model} on {device}") + model = Qwen3TTSModel.from_pretrained( + args.model, + device_map=device, + dtype=torch.bfloat16, + ) + print("Model loaded.") + + # Build prompt list + prompts = [PROMPTS[i % len(PROMPTS)] for i in range(args.num_prompts)] + + # Warmup + if args.num_warmups > 0: + print(f"Warming up with {args.num_warmups} requests...") + for i in range(args.num_warmups): + p = PROMPTS[i % len(PROMPTS)] + wavs, sr = model.generate_custom_voice( + text=p, + language=args.language, + speaker=args.voice, + ) + # Sync GPU + torch.cuda.synchronize(device) + print("Warmup done.") + + # Benchmark + print(f"Running {args.num_prompts} requests sequentially...") + e2e_times = [] + rtfs = [] + audio_durations = [] + per_request = [] + failed = 0 + + audio_dir = None + if args.save_audio: + audio_dir = Path(args.result_dir) / "audio_hf" + audio_dir.mkdir(parents=True, exist_ok=True) + + total_start = time.perf_counter() + + for i, prompt in enumerate(prompts): + try: + torch.cuda.synchronize(device) + st = time.perf_counter() + + wavs, sr = model.generate_custom_voice( + text=prompt, + language=args.language, + speaker=args.voice, + ) + + torch.cuda.synchronize(device) + elapsed = time.perf_counter() - st + + # Compute audio duration + audio_samples = wavs[0] + if isinstance(audio_samples, torch.Tensor): + audio_samples = audio_samples.cpu().numpy() + audio_dur = len(audio_samples) / sr + + rtf = elapsed / audio_dur if audio_dur > 0 else 0.0 + + e2e_times.append(elapsed) + rtfs.append(rtf) + audio_durations.append(audio_dur) + per_request.append( + { + "e2e_ms": elapsed * 1000, + "ttfp_ms": elapsed * 1000, # no streaming, TTFP = E2E + "rtf": rtf, + "audio_duration_s": audio_dur, + "prompt": prompt, + } + ) + + if audio_dir: + sf.write(str(audio_dir / f"output_{i:04d}.wav"), audio_samples, sr) + + if (i + 1) % 10 == 0 or i == 0: + print( + f" [{i + 1}/{args.num_prompts}] e2e={elapsed * 1000:.0f}ms rtf={rtf:.3f} audio={audio_dur:.2f}s" + ) + + except Exception as e: + print(f" [{i + 1}/{args.num_prompts}] FAILED: {e}") + failed += 1 + + total_duration = time.perf_counter() - total_start + completed = len(e2e_times) + + # Compute stats + result = BenchmarkResult( + config_name=args.config_name, + concurrency=1, + num_prompts=args.num_prompts, + completed=completed, + failed=failed, + duration_s=total_duration, + ) + + if e2e_times: + e2e_ms = [t * 1000 for t in e2e_times] + + result.mean_e2e_ms = float(np.mean(e2e_ms)) + result.median_e2e_ms = float(np.median(e2e_ms)) + result.std_e2e_ms = float(np.std(e2e_ms)) + result.p90_e2e_ms = float(np.percentile(e2e_ms, 90)) + result.p95_e2e_ms = float(np.percentile(e2e_ms, 95)) + result.p99_e2e_ms = float(np.percentile(e2e_ms, 99)) + + # For HF offline, TTFP = E2E (no streaming) + result.mean_ttfp_ms = result.mean_e2e_ms + result.median_ttfp_ms = result.median_e2e_ms + result.std_ttfp_ms = result.std_e2e_ms + result.p90_ttfp_ms = result.p90_e2e_ms + result.p95_ttfp_ms = result.p95_e2e_ms + result.p99_ttfp_ms = result.p99_e2e_ms + + result.mean_rtf = float(np.mean(rtfs)) + result.median_rtf = float(np.median(rtfs)) + result.std_rtf = float(np.std(rtfs)) + result.p99_rtf = float(np.percentile(rtfs, 99)) + + result.mean_audio_duration_s = float(np.mean(audio_durations)) + result.total_audio_duration_s = float(np.sum(audio_durations)) + result.audio_throughput = result.total_audio_duration_s / total_duration + result.request_throughput = completed / total_duration + result.per_request = per_request + + # Print summary in standardized performance template + W = 50 + print("") + print(f"{'=' * W}") + print(f"{'Serving Benchmark Result':^{W}}") + print(f"{'=' * W}") + print(f"{'Successful requests:':<40}{completed:<10}") + print(f"{'Failed requests:':<40}{failed:<10}") + print(f"{'Maximum request concurrency:':<40}{1:<10}") + print(f"{'Benchmark duration (s):':<40}{total_duration:<10.2f}") + print(f"{'Request throughput (req/s):':<40}{result.request_throughput:<10.2f}") + print(f"{'-' * W}") + print(f"{'End-to-end Latency':^{W}}") + print(f"{'-' * W}") + print(f"{'Mean E2EL (ms):':<40}{result.mean_e2e_ms:<10.2f}") + print(f"{'Median E2EL (ms):':<40}{result.median_e2e_ms:<10.2f}") + print(f"{'P99 E2EL (ms):':<40}{result.p99_e2e_ms:<10.2f}") + print(f"{'=' * W}") + print(f"{'Audio Result':^{W}}") + print(f"{'=' * W}") + print(f"{'Total audio duration generated (s):':<40}{result.total_audio_duration_s:<10.2f}") + print(f"{'Audio throughput (audio duration/s):':<40}{result.audio_throughput:<10.2f}") + print(f"{'-' * W}") + print(f"{'Time to First Packet':^{W}}") + print(f"{'-' * W}") + print(f"{'Mean AUDIO_TTFP (ms):':<40}{result.mean_ttfp_ms:<10.2f}") + print(f"{'Median AUDIO_TTFP (ms):':<40}{result.median_ttfp_ms:<10.2f}") + print(f"{'P99 AUDIO_TTFP (ms):':<40}{result.p99_ttfp_ms:<10.2f}") + print(f"{'-' * W}") + print(f"{'Real Time Factor':^{W}}") + print(f"{'-' * W}") + print(f"{'Mean AUDIO_RTF:':<40}{result.mean_rtf:<10.3f}") + print(f"{'Median AUDIO_RTF:':<40}{result.median_rtf:<10.3f}") + print(f"{'P99 AUDIO_RTF:':<40}{result.p99_rtf:<10.3f}") + print(f"{'=' * W}") + print("") + + # Save results (as a list with single concurrency=1 entry, matching serve format) + result_dir = Path(args.result_dir) + result_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + result_file = result_dir / f"bench_{args.config_name}_{timestamp}.json" + + with open(result_file, "w") as f: + json.dump([asdict(result)], f, indent=2) + print(f"Results saved to {result_file}") + + return result + + +def parse_args(): + parser = argparse.ArgumentParser(description="Qwen3-TTS HuggingFace Benchmark") + parser.add_argument( + "--model", type=str, default="Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice", help="HuggingFace model name or path" + ) + parser.add_argument("--num-prompts", type=int, default=50) + parser.add_argument("--num-warmups", type=int, default=3) + parser.add_argument("--gpu-device", type=int, default=0) + parser.add_argument("--voice", type=str, default="Vivian") + parser.add_argument("--language", type=str, default="English") + parser.add_argument( + "--config-name", type=str, default="hf_transformers", help="Label for this config (used in filenames)" + ) + parser.add_argument("--result-dir", type=str, default="results") + parser.add_argument("--save-audio", action="store_true", help="Save generated audio files") + return parser.parse_args() + + +if __name__ == "__main__": + args = parse_args() + run_benchmark(args) diff --git a/benchmarks/qwen3-tts/vllm_omni/bench_tts_serve.py b/benchmarks/qwen3-tts/vllm_omni/bench_tts_serve.py new file mode 100644 index 000000000..91e4ecbbb --- /dev/null +++ b/benchmarks/qwen3-tts/vllm_omni/bench_tts_serve.py @@ -0,0 +1,348 @@ +"""Benchmark client for Qwen3-TTS via /v1/audio/speech endpoint. + +Measures TTFP (Time-to-First-Packet), E2E latency, and RTF (Real-Time Factor) +across configurable concurrency levels. Saves results as JSON for plotting. + +Usage: + python bench_tts_serve.py \ + --host 127.0.0.1 --port 8000 \ + --num-prompts 50 \ + --max-concurrency 1 4 10 \ + --result-dir results/ +""" + +import argparse +import asyncio +import json +import time +from dataclasses import asdict, dataclass, field +from datetime import datetime +from pathlib import Path + +import aiohttp +import numpy as np +from tqdm.asyncio import tqdm + +PROMPTS = [ + "Hello, welcome to the voice synthesis benchmark test.", + "She said she would be here by noon, but nobody showed up.", + "The quick brown fox jumps over the lazy dog near the riverbank.", + "I can't believe how beautiful the sunset looks from up here on the mountain.", + "Please remember to bring your identification documents to the appointment tomorrow morning.", + "Have you ever wondered what it would be like to travel through time and visit ancient civilizations?", + "The restaurant on the corner serves the best pasta I have ever tasted in my entire life.", + "After the meeting, we should discuss the quarterly results and plan for the next phase.", + "Learning a new language takes patience, practice, and a genuine curiosity about other cultures.", + "The train leaves at half past seven, so we need to arrive at the station before then.", + "Could you please turn down the music a little bit, I'm trying to concentrate on my work.", + "It was a dark and stormy night when the old lighthouse keeper heard a knock at the door.", +] + + +@dataclass +class RequestResult: + success: bool = False + ttfp: float = 0.0 # Time to first audio packet (seconds) + e2e: float = 0.0 # End-to-end latency (seconds) + audio_bytes: int = 0 # Total audio bytes received + audio_duration: float = 0.0 # Audio duration in seconds (estimated from PCM) + rtf: float = 0.0 # Real-time factor = e2e / audio_duration + prompt: str = "" + error: str = "" + + +@dataclass +class BenchmarkResult: + config_name: str = "" + concurrency: int = 0 + num_prompts: int = 0 + completed: int = 0 + failed: int = 0 + duration_s: float = 0.0 + # TTFP stats (ms) + mean_ttfp_ms: float = 0.0 + median_ttfp_ms: float = 0.0 + std_ttfp_ms: float = 0.0 + p90_ttfp_ms: float = 0.0 + p95_ttfp_ms: float = 0.0 + p99_ttfp_ms: float = 0.0 + # E2E stats (ms) + mean_e2e_ms: float = 0.0 + median_e2e_ms: float = 0.0 + std_e2e_ms: float = 0.0 + p90_e2e_ms: float = 0.0 + p95_e2e_ms: float = 0.0 + p99_e2e_ms: float = 0.0 + # RTF stats + mean_rtf: float = 0.0 + median_rtf: float = 0.0 + std_rtf: float = 0.0 + p99_rtf: float = 0.0 + # Audio stats + mean_audio_duration_s: float = 0.0 + total_audio_duration_s: float = 0.0 + audio_throughput: float = 0.0 # audio_duration / wall_time + request_throughput: float = 0.0 # requests / second + # Per-request details + per_request: list = field(default_factory=list) + + +def pcm_bytes_to_duration(num_bytes: int, sample_rate: int = 24000, sample_width: int = 2) -> float: + """Convert raw PCM byte count to duration in seconds.""" + num_samples = num_bytes / sample_width + return num_samples / sample_rate + + +async def send_tts_request( + session: aiohttp.ClientSession, + api_url: str, + prompt: str, + voice: str = "vivian", + language: str = "English", + pbar: tqdm | None = None, +) -> RequestResult: + """Send a streaming TTS request and measure latency metrics.""" + payload = { + "input": prompt, + "voice": voice, + "language": language, + "stream": True, + "response_format": "pcm", + } + + result = RequestResult(prompt=prompt) + st = time.perf_counter() + + try: + async with session.post(api_url, json=payload) as response: + if response.status != 200: + result.error = f"HTTP {response.status}: {await response.text()}" + result.success = False + return result + + first_chunk = True + total_bytes = 0 + + async for chunk in response.content.iter_any(): + if first_chunk and len(chunk) > 0: + result.ttfp = time.perf_counter() - st + first_chunk = False + total_bytes += len(chunk) + + result.e2e = time.perf_counter() - st + result.audio_bytes = total_bytes + result.audio_duration = pcm_bytes_to_duration(total_bytes) + + if result.audio_duration > 0: + result.rtf = result.e2e / result.audio_duration + result.success = True + + except Exception as e: + result.error = str(e) + result.success = False + result.e2e = time.perf_counter() - st + + if pbar: + pbar.update(1) + return result + + +async def run_benchmark( + host: str, + port: int, + num_prompts: int, + max_concurrency: int, + num_warmups: int = 3, + voice: str = "vivian", + language: str = "English", +) -> BenchmarkResult: + """Run benchmark at a given concurrency level.""" + api_url = f"http://{host}:{port}/v1/audio/speech" + + connector = aiohttp.TCPConnector( + limit=max_concurrency, + limit_per_host=max_concurrency, + keepalive_timeout=60, + ) + session = aiohttp.ClientSession( + connector=connector, + timeout=aiohttp.ClientTimeout(total=600), + ) + + # Warmup + if num_warmups > 0: + print(f" Warming up with {num_warmups} requests...") + warmup_tasks = [] + for i in range(num_warmups): + prompt = PROMPTS[i % len(PROMPTS)] + warmup_tasks.append(send_tts_request(session, api_url, prompt, voice, language)) + await asyncio.gather(*warmup_tasks) + print(" Warmup done.") + + # Build request list + request_prompts = [PROMPTS[i % len(PROMPTS)] for i in range(num_prompts)] + + # Run benchmark + print(f" Running {num_prompts} requests with concurrency={max_concurrency}...") + semaphore = asyncio.Semaphore(max_concurrency) + pbar = tqdm(total=num_prompts, desc=f" concurrency={max_concurrency}") + + async def limited_request(prompt): + async with semaphore: + return await send_tts_request(session, api_url, prompt, voice, language, pbar) + + start_time = time.perf_counter() + tasks = [asyncio.create_task(limited_request(p)) for p in request_prompts] + results: list[RequestResult] = await asyncio.gather(*tasks) + duration = time.perf_counter() - start_time + pbar.close() + + await session.close() + + # Compute stats + successful = [r for r in results if r.success] + failed = [r for r in results if not r.success] + + bench = BenchmarkResult( + concurrency=max_concurrency, + num_prompts=num_prompts, + completed=len(successful), + failed=len(failed), + duration_s=duration, + ) + + if successful: + ttfps = [r.ttfp * 1000 for r in successful] # convert to ms + e2es = [r.e2e * 1000 for r in successful] + rtfs = [r.rtf for r in successful] + audio_durs = [r.audio_duration for r in successful] + + bench.mean_ttfp_ms = float(np.mean(ttfps)) + bench.median_ttfp_ms = float(np.median(ttfps)) + bench.std_ttfp_ms = float(np.std(ttfps)) + bench.p90_ttfp_ms = float(np.percentile(ttfps, 90)) + bench.p95_ttfp_ms = float(np.percentile(ttfps, 95)) + bench.p99_ttfp_ms = float(np.percentile(ttfps, 99)) + + bench.mean_e2e_ms = float(np.mean(e2es)) + bench.median_e2e_ms = float(np.median(e2es)) + bench.std_e2e_ms = float(np.std(e2es)) + bench.p90_e2e_ms = float(np.percentile(e2es, 90)) + bench.p95_e2e_ms = float(np.percentile(e2es, 95)) + bench.p99_e2e_ms = float(np.percentile(e2es, 99)) + + bench.mean_rtf = float(np.mean(rtfs)) + bench.median_rtf = float(np.median(rtfs)) + bench.std_rtf = float(np.std(rtfs)) + bench.p99_rtf = float(np.percentile(rtfs, 99)) + + bench.mean_audio_duration_s = float(np.mean(audio_durs)) + bench.total_audio_duration_s = float(np.sum(audio_durs)) + bench.audio_throughput = bench.total_audio_duration_s / duration + bench.request_throughput = len(successful) / duration + + bench.per_request = [ + { + "ttfp_ms": r.ttfp * 1000, + "e2e_ms": r.e2e * 1000, + "rtf": r.rtf, + "audio_duration_s": r.audio_duration, + "prompt": r.prompt, + } + for r in successful + ] + + # Print summary in standardized performance template + W = 50 + print("") + print(f"{'=' * W}") + print(f"{'Serving Benchmark Result':^{W}}") + print(f"{'=' * W}") + print(f"{'Successful requests:':<40}{bench.completed:<10}") + print(f"{'Failed requests:':<40}{bench.failed:<10}") + print(f"{'Maximum request concurrency:':<40}{max_concurrency:<10}") + print(f"{'Benchmark duration (s):':<40}{duration:<10.2f}") + print(f"{'Request throughput (req/s):':<40}{bench.request_throughput:<10.2f}") + print(f"{'-' * W}") + print(f"{'End-to-end Latency':^{W}}") + print(f"{'-' * W}") + print(f"{'Mean E2EL (ms):':<40}{bench.mean_e2e_ms:<10.2f}") + print(f"{'Median E2EL (ms):':<40}{bench.median_e2e_ms:<10.2f}") + print(f"{'P99 E2EL (ms):':<40}{bench.p99_e2e_ms:<10.2f}") + print(f"{'=' * W}") + print(f"{'Audio Result':^{W}}") + print(f"{'=' * W}") + print(f"{'Total audio duration generated (s):':<40}{bench.total_audio_duration_s:<10.2f}") + print(f"{'Audio throughput (audio duration/s):':<40}{bench.audio_throughput:<10.2f}") + print(f"{'-' * W}") + print(f"{'Time to First Packet':^{W}}") + print(f"{'-' * W}") + print(f"{'Mean AUDIO_TTFP (ms):':<40}{bench.mean_ttfp_ms:<10.2f}") + print(f"{'Median AUDIO_TTFP (ms):':<40}{bench.median_ttfp_ms:<10.2f}") + print(f"{'P99 AUDIO_TTFP (ms):':<40}{bench.p99_ttfp_ms:<10.2f}") + print(f"{'-' * W}") + print(f"{'Real Time Factor':^{W}}") + print(f"{'-' * W}") + print(f"{'Mean AUDIO_RTF:':<40}{bench.mean_rtf:<10.3f}") + print(f"{'Median AUDIO_RTF:':<40}{bench.median_rtf:<10.3f}") + print(f"{'P99 AUDIO_RTF:':<40}{bench.p99_rtf:<10.3f}") + print(f"{'=' * W}") + print("") + + if failed: + for r in failed[:3]: + print(f" [ERROR] {r.error[:200]}") + + return bench + + +async def main(args): + all_results = [] + + for concurrency in args.max_concurrency: + result = await run_benchmark( + host=args.host, + port=args.port, + num_prompts=args.num_prompts, + max_concurrency=concurrency, + num_warmups=args.num_warmups, + voice=args.voice, + language=args.language, + ) + result.config_name = args.config_name + all_results.append(asdict(result)) + + # Save results + result_dir = Path(args.result_dir) + result_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + result_file = result_dir / f"bench_{args.config_name}_{timestamp}.json" + + with open(result_file, "w") as f: + json.dump(all_results, f, indent=2) + print(f"Results saved to {result_file}") + + return all_results + + +def parse_args(): + parser = argparse.ArgumentParser(description="Qwen3-TTS Benchmark Client") + parser.add_argument("--host", type=str, default="127.0.0.1") + parser.add_argument("--port", type=int, default=8000) + parser.add_argument("--num-prompts", type=int, default=50, help="Number of prompts per concurrency level") + parser.add_argument( # noqa: E501 + "--max-concurrency", type=int, nargs="+", default=[1, 4, 10], help="Concurrency levels to test" + ) + parser.add_argument("--num-warmups", type=int, default=3) + parser.add_argument("--voice", type=str, default="vivian") + parser.add_argument("--language", type=str, default="English") + parser.add_argument( + "--config-name", type=str, default="async_chunk", help="Label for this config (used in filenames)" + ) + parser.add_argument("--result-dir", type=str, default="results") + return parser.parse_args() + + +if __name__ == "__main__": + args = parse_args() + asyncio.run(main(args)) diff --git a/benchmarks/qwen3-tts/vllm_omni/configs/qwen3_tts_bs1.yaml b/benchmarks/qwen3-tts/vllm_omni/configs/qwen3_tts_bs1.yaml new file mode 100644 index 000000000..1597f8aa2 --- /dev/null +++ b/benchmarks/qwen3-tts/vllm_omni/configs/qwen3_tts_bs1.yaml @@ -0,0 +1,96 @@ +# Qwen3-TTS batch_size=1 config (streaming with async_chunk) +# 2-stage pipeline: Talker -> Code2Wav +async_chunk: true +stage_args: + - stage_id: 0 + stage_type: llm + runtime: + devices: "0" + max_batch_size: 1 + engine_args: + model_stage: qwen3_tts + model_arch: Qwen3TTSTalkerForConditionalGeneration + hf_overrides: + architectures: [Qwen3TTSTalkerForConditionalGeneration] + worker_type: ar + scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler + enforce_eager: false + trust_remote_code: true + async_scheduling: false + enable_prefix_caching: false + engine_output_type: latent + gpu_memory_utilization: 0.3 + distributed_executor_backend: "mp" + max_num_batched_tokens: 512 + max_model_len: 4096 + custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_tts.talker2code2wav_async_chunk + output_connectors: + to_stage_1: connector_of_shared_memory + default_sampling_params: + temperature: 0.9 + top_k: 50 + max_tokens: 4096 + seed: 42 + detokenize: false + repetition_penalty: 1.05 + stop_token_ids: [2150] + + - stage_id: 1 + stage_type: llm + runtime: + devices: "0" + max_batch_size: 1 + engine_args: + model_stage: code2wav + model_arch: Qwen3TTSCode2Wav + hf_overrides: + architectures: [Qwen3TTSCode2Wav] + worker_type: generation + scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler + enforce_eager: true + trust_remote_code: true + async_scheduling: false + enable_prefix_caching: false + engine_output_type: audio + gpu_memory_utilization: 0.2 + distributed_executor_backend: "mp" + max_num_batched_tokens: 8192 + max_model_len: 32768 + engine_input_source: [0] + final_output: true + final_output_type: audio + input_connectors: + from_stage_0: connector_of_shared_memory + tts_args: + max_instructions_length: 500 + default_sampling_params: + temperature: 0.0 + top_p: 1.0 + top_k: -1 + max_tokens: 65536 + seed: 42 + detokenize: true + repetition_penalty: 1.0 + +runtime: + enabled: true + defaults: + window_size: -1 + max_inflight: 1 + + connectors: + connector_of_shared_memory: + name: SharedMemoryConnector + extra: + shm_threshold_bytes: 65536 + codec_streaming: true + connector_get_sleep_s: 0.01 + connector_get_max_wait_first_chunk: 3000 + connector_get_max_wait: 300 + codec_chunk_frames: 25 + codec_left_context_frames: 25 + + edges: + - from: 0 + to: 1 + window_size: -1 diff --git a/benchmarks/qwen3-tts/vllm_omni/configs/qwen3_tts_bs4.yaml b/benchmarks/qwen3-tts/vllm_omni/configs/qwen3_tts_bs4.yaml new file mode 100644 index 000000000..18493f3ae --- /dev/null +++ b/benchmarks/qwen3-tts/vllm_omni/configs/qwen3_tts_bs4.yaml @@ -0,0 +1,97 @@ +# Qwen3-TTS batch_size=4 config (streaming with async_chunk) +# Enables concurrent request processing with max_inflight=4 +# 2-stage pipeline: Talker -> Code2Wav +async_chunk: true +stage_args: + - stage_id: 0 + stage_type: llm + runtime: + devices: "0" + max_batch_size: 4 + engine_args: + model_stage: qwen3_tts + model_arch: Qwen3TTSTalkerForConditionalGeneration + hf_overrides: + architectures: [Qwen3TTSTalkerForConditionalGeneration] + worker_type: ar + scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler + enforce_eager: false + trust_remote_code: true + async_scheduling: false + enable_prefix_caching: false + engine_output_type: latent + gpu_memory_utilization: 0.3 + distributed_executor_backend: "mp" + max_num_batched_tokens: 512 + max_model_len: 4096 + custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_tts.talker2code2wav_async_chunk + output_connectors: + to_stage_1: connector_of_shared_memory + default_sampling_params: + temperature: 0.9 + top_k: 50 + max_tokens: 4096 + seed: 42 + detokenize: false + repetition_penalty: 1.05 + stop_token_ids: [2150] + + - stage_id: 1 + stage_type: llm + runtime: + devices: "0" + max_batch_size: 4 + engine_args: + model_stage: code2wav + model_arch: Qwen3TTSCode2Wav + hf_overrides: + architectures: [Qwen3TTSCode2Wav] + worker_type: generation + scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler + enforce_eager: true + trust_remote_code: true + async_scheduling: false + enable_prefix_caching: false + engine_output_type: audio + gpu_memory_utilization: 0.2 + distributed_executor_backend: "mp" + max_num_batched_tokens: 8192 + max_model_len: 32768 + engine_input_source: [0] + final_output: true + final_output_type: audio + input_connectors: + from_stage_0: connector_of_shared_memory + tts_args: + max_instructions_length: 500 + default_sampling_params: + temperature: 0.0 + top_p: 1.0 + top_k: -1 + max_tokens: 65536 + seed: 42 + detokenize: true + repetition_penalty: 1.0 + +runtime: + enabled: true + defaults: + window_size: -1 + max_inflight: 4 + + connectors: + connector_of_shared_memory: + name: SharedMemoryConnector + extra: + shm_threshold_bytes: 65536 + codec_streaming: true + connector_get_sleep_s: 0.01 + connector_get_max_wait_first_chunk: 3000 + connector_get_max_wait: 300 + codec_chunk_frames: 25 + codec_left_context_frames: 25 + + edges: + - from: 0 + to: 1 + window_size: -1 From da4a0771559de231ee37971bc9afd63e66dfc3c6 Mon Sep 17 00:00:00 2001 From: wangyu <53896905+yenuo26@users.noreply.github.com> Date: Thu, 12 Mar 2026 03:00:13 +0800 Subject: [PATCH 13/38] [Test] Skip the qwen3-omni relevant validation for a known issue 1367. (#1812) Signed-off-by: yenuo26 <410167048@qq.com> --- tests/e2e/online_serving/test_qwen3_omni.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/e2e/online_serving/test_qwen3_omni.py b/tests/e2e/online_serving/test_qwen3_omni.py index 09f528dc0..9227928f3 100644 --- a/tests/e2e/online_serving/test_qwen3_omni.py +++ b/tests/e2e/online_serving/test_qwen3_omni.py @@ -118,7 +118,6 @@ def test_mix_to_text_audio_001(omni_server, openai_client) -> None: "stream": True, "key_words": { "audio": ["test"], - "image": ["square", "quadrate"], }, } From e1efcb75607edc02f29caeb9c039b73f382252da Mon Sep 17 00:00:00 2001 From: Yueqian Lin <70319226+linyueqian@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:43:37 -0400 Subject: [PATCH 14/38] Fix duplicate get_supported_tasks definition in async_omni.py (#1825) Signed-off-by: linyueqian --- .../openai_api/test_serving_speech.py | 12 ++++++++++++ vllm_omni/entrypoints/async_omni.py | 16 ++++------------ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/tests/entrypoints/openai_api/test_serving_speech.py b/tests/entrypoints/openai_api/test_serving_speech.py index 47790286a..7c520e889 100644 --- a/tests/entrypoints/openai_api/test_serving_speech.py +++ b/tests/entrypoints/openai_api/test_serving_speech.py @@ -1025,19 +1025,31 @@ class TestAsyncOmniSupportedTasks: @pytest.mark.asyncio async def test_tts_only_no_generate_task(self): """TTS-only models (audio output, no text) should not include 'generate'.""" + from unittest.mock import MagicMock + from vllm_omni.entrypoints.async_omni import AsyncOmni omni = AsyncOmni.__new__(AsyncOmni) omni.output_modalities = [None, "audio"] + stage = MagicMock() + stage.is_comprehension = False + omni.stage_list = [stage] tasks = await omni.get_supported_tasks() assert "generate" not in tasks + assert "speech" in tasks @pytest.mark.asyncio async def test_omni_model_includes_generate(self): """Models with text output (e.g. Qwen3-Omni) should include 'generate'.""" + from unittest.mock import MagicMock + from vllm_omni.entrypoints.async_omni import AsyncOmni omni = AsyncOmni.__new__(AsyncOmni) omni.output_modalities = ["text", None, "audio"] + stage = MagicMock() + stage.is_comprehension = True + omni.stage_list = [stage] tasks = await omni.get_supported_tasks() assert "generate" in tasks + assert "speech" in tasks diff --git a/vllm_omni/entrypoints/async_omni.py b/vllm_omni/entrypoints/async_omni.py index 0761ae6ef..1ba95124a 100644 --- a/vllm_omni/entrypoints/async_omni.py +++ b/vllm_omni/entrypoints/async_omni.py @@ -141,10 +141,12 @@ class AsyncOmni(OmniBase): ) async def get_supported_tasks(self) -> set[str]: - """Return supported tasks based on the configured stage output modalities.""" + """Return supported tasks based on stage output modalities and capabilities.""" tasks: set[str] = set() - if "text" in self.output_modalities: + if "text" in self.output_modalities or any(stage.is_comprehension for stage in self.stage_list): tasks.add("generate") + if "audio" in self.output_modalities: + tasks.add("speech") return tasks def _create_default_diffusion_stage_cfg(self, kwargs: dict[str, Any]) -> dict[str, Any]: @@ -838,16 +840,6 @@ class AsyncOmni(OmniBase): stage.submit(abort_task) return None - async def get_supported_tasks(self) -> set[str]: - tasks: set[str] = set() - has_comprehension = any(stage.is_comprehension for stage in self.stage_list) - if has_comprehension: - tasks.add("generate") - for stage in self.stage_list: - if getattr(stage, "final_output_type", None) == "audio": - tasks.add("speech") - return tasks if tasks else {"generate"} - async def get_vllm_config(self) -> VllmConfig: for stage in self.stage_list: if stage.is_comprehension: From ac174317455a4e1a3ee24e9ccd6d66c2ddfd8dd4 Mon Sep 17 00:00:00 2001 From: Daniel Huang Date: Wed, 11 Mar 2026 19:40:42 -0700 Subject: [PATCH 15/38] [Enhancement] Patch OmniStage.try_collect() with _proc alive checks (#1560) Signed-off-by: Daniel Huang --- tests/entrypoints/test_omni_diffusion.py | 2 +- vllm_omni/entrypoints/omni_stage.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/entrypoints/test_omni_diffusion.py b/tests/entrypoints/test_omni_diffusion.py index 90b0fd053..9e555fa85 100644 --- a/tests/entrypoints/test_omni_diffusion.py +++ b/tests/entrypoints/test_omni_diffusion.py @@ -345,7 +345,7 @@ def _setup_multiprocessing_mocks(monkeypatch: pytest.MonkeyPatch, mocker: Mocker fake_process_instance = mocker.MagicMock() fake_process_instance.start = mocker.MagicMock() fake_process_instance.join = mocker.MagicMock() - fake_process_instance.is_alive = mocker.MagicMock(return_value=False) + fake_process_instance.is_alive = mocker.MagicMock(return_value=True) fake_process_instance.terminate = mocker.MagicMock() fake_process_class.return_value = fake_process_instance diff --git a/vllm_omni/entrypoints/omni_stage.py b/vllm_omni/entrypoints/omni_stage.py index 3fa722b03..19091034a 100644 --- a/vllm_omni/entrypoints/omni_stage.py +++ b/vllm_omni/entrypoints/omni_stage.py @@ -693,8 +693,14 @@ class OmniStage: assert self._out_q is not None try: return self._out_q.get_nowait() - except Exception: - return None + except queue.Empty: + pass + except Exception as e: + logger.error("Unexpected error when collecting OmniStage output queue:", exc_info=e) + self.stop_stage_worker() + raise + if self._proc is not None and not self._proc.is_alive(): + raise RuntimeError(f"OmniStage Worker process died unexpectedly with exit code {self._proc.exitcode}") def process_engine_inputs( self, From 09b66bf6dbe1ac02b4ee721ff36be286b39e39d6 Mon Sep 17 00:00:00 2001 From: Alicia <115451386+congw729@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:06:36 +0800 Subject: [PATCH 16/38] [Doc][skip ci] Update readme with Video link for vLLM HK First Meetup (#1833) Signed-off-by: Alicia <115451386+congw729@users.noreply.github.com> --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index df5d093b1..74f61b5fd 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Easy, fast, and cheap omni-modality model serving for everyone *Latest News* 🔥 +- [2026/03] Joined vLLM HK First Meetup! Video on [youtube](https://youtu.be/sgwNfsNnR9I) - [2026/03] **[vllm-omni-skills](https://github.com/hsliuustc0106/vllm-omni-skills)** is a community-driven collection of AI assistant skills that help developers work with vLLM-Omni more effectively. These skills can be used with popular agentic AI coding assistants like **Cursor IDE**, **Claude**, **Codex**, and more. - [2026/02] We released [0.16.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.16.0) - A major alignment + capability release that rebases onto **upstream vLLM v0.16.0** and significantly expands performance, distributed execution, and production readiness across **Qwen3-Omni / Qwen3-TTS**, **Bagel**, **MiMo-Audio**, **GLM-Image** and the **Diffusion (DiT) image/video stack**—while also improving platform coverage (CUDA / ROCm / NPU / XPU), CI quality, and documentation. - [2026/02] We released [0.14.0](https://github.com/vllm-project/vllm-omni/releases/tag/v0.14.0) - This is the first **stable release** of vLLM-Omni that expands Omni’s diffusion / image-video generation and audio / TTS stack, improves distributed execution and memory efficiency, and broadens platform/backend coverage (GPU/ROCm/NPU/XPU). It also brings meaningful upgrades to serving APIs, profiling & benchmarking, and overall stability. Please check our latest [paper](https://arxiv.org/abs/2602.02204) for architecture design and performance results. From 5d28b6a40c1d10466e9d55d918251d6df3201c69 Mon Sep 17 00:00:00 2001 From: Sy03 <1370724210@qq.com> Date: Thu, 12 Mar 2026 11:48:41 +0800 Subject: [PATCH 17/38] [Feat][Qwen3-TTS] Support streaming audio output for websocket (#1719) Signed-off-by: Sy03 <1370724210@qq.com> Signed-off-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com> Signed-off-by: linyueqian Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com> Co-authored-by: linyueqian --- docs/serving/speech_api.md | 36 ++ examples/online_serving/qwen3_tts/README.md | 36 ++ .../qwen3_tts/streaming_speech_client.py | 249 +++++++++++ .../test_qwen3_tts_websocket.py | 146 +++++++ .../openai_api/test_serving_speech.py | 39 +- .../openai_api/test_serving_speech_stream.py | 387 ++++++++++++++++++ .../openai_api/test_text_splitter.py | 253 ++++++++++++ vllm_omni/entrypoints/openai/api_server.py | 208 ++-------- .../entrypoints/openai/protocol/audio.py | 49 +++ .../entrypoints/openai/serving_speech.py | 172 ++++---- .../openai/serving_speech_stream.py | 289 +++++++++++++ vllm_omni/entrypoints/openai/text_splitter.py | 120 ++++++ 12 files changed, 1719 insertions(+), 265 deletions(-) create mode 100644 examples/online_serving/qwen3_tts/streaming_speech_client.py create mode 100644 tests/e2e/online_serving/test_qwen3_tts_websocket.py create mode 100644 tests/entrypoints/openai_api/test_serving_speech_stream.py create mode 100644 tests/entrypoints/openai_api/test_text_splitter.py create mode 100644 vllm_omni/entrypoints/openai/serving_speech_stream.py create mode 100644 vllm_omni/entrypoints/openai/text_splitter.py diff --git a/docs/serving/speech_api.md b/docs/serving/speech_api.md index 2f0695298..c346b5b97 100644 --- a/docs/serving/speech_api.md +++ b/docs/serving/speech_api.md @@ -164,6 +164,42 @@ curl -X POST http://localhost:8091/v1/audio/voices \ -F "name=custom_voice_1" ``` +## Streaming Text Input (WebSocket) + +The `/v1/audio/speech/stream` WebSocket endpoint accepts text incrementally and generates audio per sentence as boundaries are detected. + +> Note: text input is always streamed incrementally. Audio output remains sentence-scoped: +> use `stream_audio=false` for one binary frame per sentence, or `stream_audio=true` for one or more PCM chunks per sentence. + +### WebSocket Protocol + +Client -> Server: + +| Message | Description | +|---------|-------------| +| `{"type": "session.config", ...}` | Session configuration (sent once, first message) | +| `{"type": "input.text", "text": "..."}` | Text chunk | +| `{"type": "input.done"}` | End of input, flushes remaining buffer | + +Server -> Client: + +| Message | Description | +|---------|-------------| +| `{"type": "audio.start", "sentence_index": 0, "sentence_text": "...", "format": "pcm", "sample_rate": 24000}` | Audio generation starting for a sentence | +| Binary frame | Raw audio bytes (one or more PCM chunks when `stream_audio=true`) | +| `{"type": "audio.done", "sentence_index": 0, "total_bytes": 96000, "error": false}` | Audio complete for a sentence | +| `{"type": "session.done", "total_sentences": N}` | Session complete | +| `{"type": "error", "message": "..."}` | Non-fatal error | + +### Session Config Parameters + +All REST API parameters are supported, plus: + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `stream_audio` | bool | false | Stream one or more PCM chunks per sentence over WebSocket | +| `split_granularity` | string | "sentence" | Text splitting granularity | + ```bash DELETE /v1/audio/voices/{name} diff --git a/examples/online_serving/qwen3_tts/README.md b/examples/online_serving/qwen3_tts/README.md index 99442c29e..4709c9d42 100644 --- a/examples/online_serving/qwen3_tts/README.md +++ b/examples/online_serving/qwen3_tts/README.md @@ -349,6 +349,42 @@ curl -X POST http://localhost:8091/v1/audio/speech \ - `speed` adjustment is not supported when streaming. - Requires the server stage config to have `async_chunk: true` (default in `qwen3_tts.yaml`). +## Streaming Text Input (WebSocket) + +The `/v1/audio/speech/stream` WebSocket endpoint accepts text incrementally, buffers and splits it at sentence boundaries, and generates audio per sentence. + +When `stream_audio=true`, each sentence is emitted as `audio.start`, one or more binary PCM frames, and `audio.done`. + +### Quick Start + +```bash +python streaming_speech_client.py \ + --text "Hello world. How are you? I am fine." + +python streaming_speech_client.py \ + --text "Hello world. How are you? I am fine." \ + --simulate-stt --stt-delay 0.1 +``` + +### WebSocket Protocol + +Client -> Server: + +```jsonc +{"type": "session.config", "voice": "Vivian", "task_type": "CustomVoice", "language": "Auto", "split_granularity": "sentence", "stream_audio": true, "response_format": "pcm"} +{"type": "input.text", "text": "Hello, how are you? "} +{"type": "input.done"} +``` + +Server -> Client: + +```jsonc +{"type": "audio.start", "sentence_index": 0, "sentence_text": "Hello, how are you?", "format": "pcm", "sample_rate": 24000} +// binary PCM frame(s) +{"type": "audio.done", "sentence_index": 0, "total_bytes": 96000, "error": false} +{"type": "session.done", "total_sentences": 1} +``` + ## Limitations - **Single request**: Batch processing is not yet optimized for online serving. diff --git a/examples/online_serving/qwen3_tts/streaming_speech_client.py b/examples/online_serving/qwen3_tts/streaming_speech_client.py new file mode 100644 index 000000000..785c6a0e8 --- /dev/null +++ b/examples/online_serving/qwen3_tts/streaming_speech_client.py @@ -0,0 +1,249 @@ +"""WebSocket client for streaming text-input TTS. + +Connects to the /v1/audio/speech/stream endpoint, sends text incrementally +(simulating real-time STT output), and saves per-sentence audio files. + +Usage: + # Send full text at once + python streaming_speech_client.py --text "Hello world. How are you? I am fine." + + # Simulate STT: send text word-by-word with delay + python streaming_speech_client.py \ + --text "Hello world. How are you? I am fine." \ + --simulate-stt --stt-delay 0.1 + + # VoiceDesign task + python streaming_speech_client.py \ + --text "Today is a great day. The weather is nice." \ + --task-type VoiceDesign \ + --instructions "A cheerful young female voice" + + # Base task (voice cloning) + python streaming_speech_client.py \ + --text "Hello world. How are you?" \ + --task-type Base \ + --ref-audio /path/to/reference.wav \ + --ref-text "Transcript of reference audio" + +Requirements: + pip install websockets +""" + +import argparse +import asyncio +import json +import os + +try: + import websockets +except ImportError: + print("Please install websockets: pip install websockets") + raise SystemExit(1) + + +async def stream_tts( + url: str, + text: str, + config: dict, + output_dir: str, + simulate_stt: bool = False, + stt_delay: float = 0.1, +) -> None: + """Connect to the streaming TTS endpoint and process audio responses.""" + os.makedirs(output_dir, exist_ok=True) + + async with websockets.connect(url) as ws: + # 1. Send session config + config_msg = {"type": "session.config", **config} + await ws.send(json.dumps(config_msg)) + print(f"Sent session config: {config}") + + # 2. Send text (either all at once or word-by-word) + async def send_text(): + if simulate_stt: + words = text.split(" ") + for i, word in enumerate(words): + chunk = word + (" " if i < len(words) - 1 else "") + await ws.send( + json.dumps( + { + "type": "input.text", + "text": chunk, + } + ) + ) + print(f" Sent: {chunk!r}") + await asyncio.sleep(stt_delay) + else: + await ws.send( + json.dumps( + { + "type": "input.text", + "text": text, + } + ) + ) + print(f"Sent full text: {text!r}") + + # 3. Signal end of input + await ws.send(json.dumps({"type": "input.done"})) + print("Sent input.done") + + # Run sender and receiver concurrently + sender_task = asyncio.create_task(send_text()) + + response_format = config.get("response_format", "wav") + current_sentence_index = 0 + current_chunks: list[bytes] = [] + + try: + while True: + message = await ws.recv() + + if isinstance(message, bytes): + current_chunks.append(message) + print(f" Received audio chunk for sentence {current_sentence_index}: {len(message)} bytes") + else: + # JSON frame + msg = json.loads(message) + msg_type = msg.get("type") + + if msg_type == "audio.start": + current_sentence_index = msg["sentence_index"] + current_chunks = [] + print(f" [sentence {msg['sentence_index']}] Generating: {msg['sentence_text']!r}") + elif msg_type == "audio.done": + filename = os.path.join( + output_dir, + f"sentence_{msg['sentence_index']:03d}.{response_format}", + ) + with open(filename, "wb") as f: + f.write(b"".join(current_chunks)) + print( + f" [sentence {msg['sentence_index']}] Done" + f" bytes={msg.get('total_bytes', len(b''.join(current_chunks)))}" + f" error={msg.get('error', False)}" + f" -> {filename}" + ) + current_chunks = [] + elif msg_type == "session.done": + print(f"\nSession complete: {msg['total_sentences']} sentence(s) generated") + break + elif msg_type == "error": + print(f" ERROR: {msg['message']}") + else: + print(f" Unknown message: {msg}") + finally: + sender_task.cancel() + try: + await sender_task + except asyncio.CancelledError: + pass # Task cancellation is expected during shutdown + + print(f"\nAudio files saved to: {output_dir}/") + + +def main(): + parser = argparse.ArgumentParser(description="Streaming text-input TTS client") + parser.add_argument( + "--url", + default="ws://localhost:8000/v1/audio/speech/stream", + help="WebSocket endpoint URL", + ) + parser.add_argument( + "--text", + required=True, + help="Text to synthesize", + ) + parser.add_argument( + "--output-dir", + default="streaming_tts_output", + help="Directory to save audio files (default: streaming_tts_output)", + ) + + # Session config options + parser.add_argument("--model", default=None, help="Model name") + parser.add_argument("--voice", default="Vivian", help="Speaker voice") + parser.add_argument( + "--task-type", + default="CustomVoice", + choices=["CustomVoice", "VoiceDesign", "Base"], + help="TTS task type", + ) + parser.add_argument("--language", default="Auto", help="Language") + parser.add_argument("--instructions", default=None, help="Voice style instructions") + parser.add_argument( + "--response-format", + default="wav", + choices=["wav", "pcm", "flac", "mp3", "aac", "opus"], + help="Audio format", + ) + parser.add_argument( + "--stream-audio", + action="store_true", + help="Receive one or more PCM chunks per sentence (requires --response-format pcm)", + ) + parser.add_argument("--speed", type=float, default=1.0, help="Playback speed (0.25-4.0)") + parser.add_argument("--max-new-tokens", type=int, default=None, help="Max tokens") + + # Base task options + parser.add_argument("--ref-audio", default=None, help="Reference audio") + parser.add_argument("--ref-text", default=None, help="Reference text") + parser.add_argument( + "--x-vector-only-mode", + action="store_true", + default=False, + help="Speaker embedding only mode", + ) + + # STT simulation + parser.add_argument( + "--simulate-stt", + action="store_true", + help="Simulate STT by sending text word-by-word", + ) + parser.add_argument( + "--stt-delay", + type=float, + default=0.1, + help="Delay between words in STT simulation (seconds)", + ) + + args = parser.parse_args() + + # Build session config (only include non-None values) + config = {} + for key in [ + "model", + "voice", + "task_type", + "language", + "instructions", + "response_format", + "speed", + "max_new_tokens", + "ref_audio", + "ref_text", + ]: + val = getattr(args, key.replace("-", "_"), None) + if val is not None: + config[key] = val + if args.stream_audio: + config["stream_audio"] = True + if args.x_vector_only_mode: + config["x_vector_only_mode"] = True + + asyncio.run( + stream_tts( + url=args.url, + text=args.text, + config=config, + output_dir=args.output_dir, + simulate_stt=args.simulate_stt, + stt_delay=args.stt_delay, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/online_serving/test_qwen3_tts_websocket.py b/tests/e2e/online_serving/test_qwen3_tts_websocket.py new file mode 100644 index 000000000..df0514601 --- /dev/null +++ b/tests/e2e/online_serving/test_qwen3_tts_websocket.py @@ -0,0 +1,146 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +""" +E2E online tests for Qwen3-TTS WebSocket streaming speech. +""" + +import asyncio +import json +import os +from pathlib import Path + +import pytest +import websockets + +from tests.conftest import OmniServer +from tests.utils import hardware_test + +os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" +os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0" + +MODEL = "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice" + + +def get_stage_config() -> str: + return str( + Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / "qwen3_tts.yaml" + ) + + +@pytest.fixture(scope="module") +def omni_server(): + stage_config_path = get_stage_config() + + with OmniServer( + MODEL, + [ + "--stage-configs-path", + stage_config_path, + "--stage-init-timeout", + "120", + "--trust-remote-code", + "--enforce-eager", + "--disable-log-stats", + ], + env_dict={"VLLM_DISABLE_COMPILE_CACHE": "1"}, + ) as server: + yield server + + +async def _run_ws_session(host: str, port: int) -> dict: + uri = f"ws://{host}:{port}/v1/audio/speech/stream" + starts: list[dict] = [] + dones: list[dict] = [] + chunk_lengths: dict[int, list[int]] = {} + session_done: dict | None = None + + async with websockets.connect(uri, max_size=None) as ws: + await ws.send( + json.dumps( + { + "type": "session.config", + "model": MODEL, + "voice": "vivian", + "language": "English", + "response_format": "pcm", + "stream_audio": True, + } + ) + ) + await ws.send( + json.dumps( + { + "type": "input.text", + "text": ( + "Hello, this is a websocket streaming test for Qwen three TTS, " + "and this sentence is intentionally long enough to produce audio chunks. " + "This is the second sentence." + ), + } + ) + ) + await ws.send(json.dumps({"type": "input.done"})) + + while True: + message = await asyncio.wait_for(ws.recv(), timeout=180) + if isinstance(message, bytes): + if not starts: + raise AssertionError("Received audio bytes before audio.start") + sentence_index = starts[-1]["sentence_index"] + chunk_lengths.setdefault(sentence_index, []).append(len(message)) + continue + + payload = json.loads(message) + msg_type = payload.get("type") + if msg_type == "audio.start": + starts.append(payload) + chunk_lengths.setdefault(payload["sentence_index"], []) + elif msg_type == "audio.done": + dones.append(payload) + elif msg_type == "session.done": + session_done = payload + break + elif msg_type == "error": + raise AssertionError(f"WebSocket error: {payload['message']}") + else: + raise AssertionError(f"Unexpected WebSocket message: {payload}") + + return { + "starts": starts, + "dones": dones, + "chunk_lengths": chunk_lengths, + "session_done": session_done, + } + + +class TestQwen3TTSWebSocket: + @pytest.mark.core_model + @pytest.mark.omni + @hardware_test(res={"cuda": "L4"}, num_cards=4) + def test_streaming_pcm_output(self, omni_server) -> None: + result = asyncio.run(_run_ws_session(omni_server.host, omni_server.port)) + + starts = result["starts"] + dones = result["dones"] + chunk_lengths = result["chunk_lengths"] + session_done = result["session_done"] + + assert session_done is not None + assert session_done["total_sentences"] == 2 + assert len(starts) == 2 + assert len(dones) == 2 + + for idx, start in enumerate(starts): + assert start["type"] == "audio.start" + assert start["sentence_index"] == idx + assert start["format"] == "pcm" + assert start["sample_rate"] == 24000 + assert start["sentence_text"] + + for done in dones: + sentence_index = done["sentence_index"] + total_bytes = done["total_bytes"] + assert done["error"] is False + assert total_bytes > 0 + assert chunk_lengths[sentence_index], f"Expected binary PCM frames for sentence {sentence_index}" + assert sum(chunk_lengths[sentence_index]) == total_bytes diff --git a/tests/entrypoints/openai_api/test_serving_speech.py b/tests/entrypoints/openai_api/test_serving_speech.py index 7c520e889..7e35fec0d 100644 --- a/tests/entrypoints/openai_api/test_serving_speech.py +++ b/tests/entrypoints/openai_api/test_serving_speech.py @@ -1,19 +1,23 @@ # tests/entrypoints/openai/test_serving_speech.py +import asyncio import logging import os from inspect import Signature, signature from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import numpy as np import pytest import torch -from fastapi import FastAPI, HTTPException, UploadFile +from fastapi import FastAPI, HTTPException, Request, UploadFile from fastapi.params import File, Form +from fastapi.responses import JSONResponse from fastapi.testclient import TestClient from pydantic import ValidationError from pytest_mock import MockerFixture +from vllm.entrypoints.openai.engine.protocol import ErrorInfo, ErrorResponse +from vllm_omni.entrypoints.openai import api_server as api_server_module from vllm_omni.entrypoints.openai.audio_utils_mixin import AudioMixin from vllm_omni.entrypoints.openai.protocol.audio import CreateAudio, OpenAICreateSpeechRequest from vllm_omni.entrypoints.openai.serving_speech import ( @@ -1052,4 +1056,33 @@ class TestAsyncOmniSupportedTasks: omni.stage_list = [stage] tasks = await omni.get_supported_tasks() assert "generate" in tasks - assert "speech" in tasks + + +def test_api_server_create_speech_wraps_error_response_status(): + handler = MagicMock() + handler.create_speech = AsyncMock( + return_value=ErrorResponse( + error=ErrorInfo(message="bad request", type="BadRequestError", param=None, code=400), + ) + ) + + app = FastAPI() + app.state.openai_serving_speech = handler + scope = { + "type": "http", + "app": app, + "method": "POST", + "path": "/v1/audio/speech", + "headers": [], + "query_string": b"", + "client": ("127.0.0.1", 12345), + "server": ("testserver", 80), + "scheme": "http", + } + raw_request = Request(scope) + request = OpenAICreateSpeechRequest(input="Hello") + + response = asyncio.run(api_server_module.create_speech(request, raw_request)) + + assert isinstance(response, JSONResponse) + assert response.status_code == 400 diff --git a/tests/entrypoints/openai_api/test_serving_speech_stream.py b/tests/entrypoints/openai_api/test_serving_speech_stream.py new file mode 100644 index 000000000..bd136ac72 --- /dev/null +++ b/tests/entrypoints/openai_api/test_serving_speech_stream.py @@ -0,0 +1,387 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI, WebSocket +from starlette.testclient import TestClient +from starlette.websockets import WebSocketDisconnect + +from vllm_omni.entrypoints.openai import serving_speech_stream as streaming_speech_module +from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech +from vllm_omni.entrypoints.openai.serving_speech_stream import OmniStreamingSpeechHandler + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _build_test_app(speech_service=None, *, idle_timeout=30.0, config_timeout=10.0): + if speech_service is None: + speech_service = MagicMock(spec=OmniOpenAIServingSpeech) + speech_service._generate_audio_bytes = AsyncMock(return_value=(b"RIFF" + b"\x00" * 32, "audio/wav")) + speech_service._prepare_speech_generation = AsyncMock(return_value=("req-1", object(), {})) + + async def mock_generate_pcm_chunks(_generator, _request_id): + for chunk in (b"\x01\x02", b"\x03\x04\x05"): + yield chunk + + speech_service._generate_pcm_chunks = mock_generate_pcm_chunks + speech_service.engine_client = MagicMock() + speech_service.engine_client.abort = AsyncMock() + + handler = OmniStreamingSpeechHandler( + speech_service=speech_service, + idle_timeout=idle_timeout, + config_timeout=config_timeout, + ) + app = FastAPI() + + @app.websocket("/v1/audio/speech/stream") + async def ws_endpoint(websocket: WebSocket): + await handler.handle_session(websocket) + + return app, speech_service + + +class TestStreamingSpeechWebSocket: + def test_non_streaming_single_frame(self): + app, speech_service = _build_test_app() + + with TestClient(app) as client: + with client.websocket_connect("/v1/audio/speech/stream") as ws: + ws.send_json({"type": "session.config", "voice": "Vivian"}) + ws.send_json({"type": "input.text", "text": "Hello world. "}) + + start = ws.receive_json() + assert start["type"] == "audio.start" + assert start["sentence_index"] == 0 + assert start["sentence_text"] == "Hello world." + assert start["format"] == "wav" + + audio = ws.receive_bytes() + assert audio.startswith(b"RIFF") + + done = ws.receive_json() + assert done == {"type": "audio.done", "sentence_index": 0, "total_bytes": len(audio), "error": False} + + ws.send_json({"type": "input.done"}) + session_done = ws.receive_json() + assert session_done == {"type": "session.done", "total_sentences": 1} + + assert speech_service._generate_audio_bytes.await_count == 1 + + def test_streaming_multiple_binary_frames(self): + captured_requests = [] + + speech_service = MagicMock(spec=OmniOpenAIServingSpeech) + speech_service._generate_audio_bytes = AsyncMock(return_value=(b"", "audio/wav")) + speech_service.engine_client = MagicMock() + speech_service.engine_client.abort = AsyncMock() + + async def mock_prepare_speech_generation(request): + captured_requests.append(request) + return "req-stream", object(), {} + + speech_service._prepare_speech_generation = mock_prepare_speech_generation + + async def mock_generate_pcm_chunks(_generator, _request_id): + for chunk in (b"\x01\x02", b"\x03\x04\x05", b"\x06"): + yield chunk + + speech_service._generate_pcm_chunks = mock_generate_pcm_chunks + app, _ = _build_test_app(speech_service) + + with TestClient(app) as client: + with client.websocket_connect("/v1/audio/speech/stream") as ws: + ws.send_json( + { + "type": "session.config", + "voice": "Vivian", + "stream_audio": True, + "response_format": "pcm", + "initial_codec_chunk_frames": 12, + } + ) + ws.send_json({"type": "input.text", "text": "Hello world. "}) + + start = ws.receive_json() + assert start["type"] == "audio.start" + assert start["format"] == "pcm" + assert start["sample_rate"] == 24000 + + assert ws.receive_bytes() == b"\x01\x02" + assert ws.receive_bytes() == b"\x03\x04\x05" + assert ws.receive_bytes() == b"\x06" + + done = ws.receive_json() + assert done == {"type": "audio.done", "sentence_index": 0, "total_bytes": 6, "error": False} + + ws.send_json({"type": "input.done"}) + assert ws.receive_json() == {"type": "session.done", "total_sentences": 1} + + assert len(captured_requests) == 1 + assert captured_requests[0].stream is True + assert captured_requests[0].response_format == "pcm" + assert captured_requests[0].initial_codec_chunk_frames == 12 + assert speech_service._generate_audio_bytes.await_count == 0 + + def test_flush_on_input_done(self): + app, _ = _build_test_app() + + with TestClient(app) as client: + with client.websocket_connect("/v1/audio/speech/stream") as ws: + ws.send_json({"type": "session.config", "voice": "Vivian"}) + ws.send_json({"type": "input.text", "text": "Hello world without punctuation"}) + ws.send_json({"type": "input.done"}) + + assert ws.receive_json()["type"] == "audio.start" + assert ws.receive_bytes() + assert ws.receive_json() == { + "type": "audio.done", + "sentence_index": 0, + "total_bytes": 36, + "error": False, + } + assert ws.receive_json() == {"type": "session.done", "total_sentences": 1} + + def test_invalid_streaming_config(self): + app, _ = _build_test_app() + + with TestClient(app) as client: + with client.websocket_connect("/v1/audio/speech/stream") as ws: + ws.send_json( + { + "type": "session.config", + "voice": "Vivian", + "stream_audio": True, + "response_format": "wav", + } + ) + error = ws.receive_json() + assert error["type"] == "error" + assert "response_format='pcm'" in error["message"] + + def test_empty_input_text_emits_no_audio(self): + app, speech_service = _build_test_app() + + with TestClient(app) as client: + with client.websocket_connect("/v1/audio/speech/stream") as ws: + ws.send_json({"type": "session.config", "voice": "Vivian"}) + ws.send_json({"type": "input.text", "text": ""}) + ws.send_json({"type": "input.done"}) + + assert ws.receive_json() == {"type": "session.done", "total_sentences": 0} + + assert speech_service._generate_audio_bytes.await_count == 0 + + def test_multiple_sentences_increment_indices(self): + app, _ = _build_test_app() + + with TestClient(app) as client: + with client.websocket_connect("/v1/audio/speech/stream") as ws: + ws.send_json({"type": "session.config", "voice": "Vivian"}) + ws.send_json({"type": "input.text", "text": "First sentence. Second sentence. "}) + + first_start = ws.receive_json() + assert first_start["sentence_index"] == 0 + ws.receive_bytes() + assert ws.receive_json() == { + "type": "audio.done", + "sentence_index": 0, + "total_bytes": 36, + "error": False, + } + + second_start = ws.receive_json() + assert second_start["sentence_index"] == 1 + ws.receive_bytes() + assert ws.receive_json() == { + "type": "audio.done", + "sentence_index": 1, + "total_bytes": 36, + "error": False, + } + + ws.send_json({"type": "input.done"}) + assert ws.receive_json() == {"type": "session.done", "total_sentences": 2} + + def test_unknown_message_type_keeps_session_open(self): + app, _ = _build_test_app() + + with TestClient(app) as client: + with client.websocket_connect("/v1/audio/speech/stream") as ws: + ws.send_json({"type": "session.config", "voice": "Vivian"}) + ws.send_json({"type": "unknown"}) + + error = ws.receive_json() + assert error == {"type": "error", "message": "Unknown message type: unknown"} + + ws.send_json({"type": "input.text", "text": "Hello world. "}) + assert ws.receive_json()["type"] == "audio.start" + ws.receive_bytes() + assert ws.receive_json() == { + "type": "audio.done", + "sentence_index": 0, + "total_bytes": 36, + "error": False, + } + + ws.send_json({"type": "input.done"}) + assert ws.receive_json() == {"type": "session.done", "total_sentences": 1} + + def test_config_timeout_closes_session(self): + app, _ = _build_test_app(config_timeout=0.01) + + with TestClient(app) as client: + with client.websocket_connect("/v1/audio/speech/stream") as ws: + error = ws.receive_json() + assert error == {"type": "error", "message": "Timeout waiting for session.config"} + + def test_generation_error_marks_audio_done(self): + speech_service = MagicMock(spec=OmniOpenAIServingSpeech) + speech_service._generate_audio_bytes = AsyncMock(side_effect=RuntimeError("boom")) + speech_service._prepare_speech_generation = AsyncMock(return_value=("req-err", object(), {})) + speech_service._generate_pcm_chunks = AsyncMock() + speech_service.engine_client = MagicMock() + speech_service.engine_client.abort = AsyncMock() + app, _ = _build_test_app(speech_service) + + with TestClient(app) as client: + with client.websocket_connect("/v1/audio/speech/stream") as ws: + ws.send_json({"type": "session.config", "voice": "Vivian"}) + ws.send_json({"type": "input.text", "text": "Hello world. "}) + + assert ws.receive_json()["type"] == "audio.start" + assert ws.receive_json() == {"type": "error", "message": "Generation failed for sentence 0: boom"} + assert ws.receive_json() == {"type": "audio.done", "sentence_index": 0, "total_bytes": 0, "error": True} + + ws.send_json({"type": "input.done"}) + assert ws.receive_json() == {"type": "session.done", "total_sentences": 1} + + def test_streaming_generation_error_marks_audio_done(self): + speech_service = MagicMock(spec=OmniOpenAIServingSpeech) + speech_service._generate_audio_bytes = AsyncMock(return_value=(b"", "audio/wav")) + speech_service._prepare_speech_generation = AsyncMock(return_value=("req-stream-err", object(), {})) + speech_service.engine_client = MagicMock() + speech_service.engine_client.abort = AsyncMock() + + async def mock_generate_pcm_chunks(_generator, _request_id): + yield b"\x01\x02" + raise RuntimeError("stream boom") + + speech_service._generate_pcm_chunks = mock_generate_pcm_chunks + app, _ = _build_test_app(speech_service) + + with TestClient(app) as client: + with client.websocket_connect("/v1/audio/speech/stream") as ws: + ws.send_json( + { + "type": "session.config", + "voice": "Vivian", + "stream_audio": True, + "response_format": "pcm", + } + ) + ws.send_json({"type": "input.text", "text": "Hello world. "}) + + assert ws.receive_json()["type"] == "audio.start" + assert ws.receive_bytes() == b"\x01\x02" + assert ws.receive_json() == { + "type": "error", + "message": "Generation failed for sentence 0: stream boom", + } + assert ws.receive_json() == { + "type": "audio.done", + "sentence_index": 0, + "total_bytes": 2, + "error": True, + } + + ws.send_json({"type": "input.done"}) + assert ws.receive_json() == {"type": "session.done", "total_sentences": 1} + + def test_invalid_input_text_type_returns_validation_error(self): + app, speech_service = _build_test_app() + + with TestClient(app) as client: + with client.websocket_connect("/v1/audio/speech/stream") as ws: + ws.send_json({"type": "session.config", "voice": "Vivian"}) + ws.send_json({"type": "input.text", "text": 123}) + + assert ws.receive_json() == { + "type": "error", + "message": "input.text requires a string value", + } + + ws.send_json({"type": "input.done"}) + assert ws.receive_json() == {"type": "session.done", "total_sentences": 0} + + assert speech_service._generate_audio_bytes.await_count == 0 + + def test_input_text_message_too_large(self, monkeypatch): + monkeypatch.setattr(streaming_speech_module, "_MAX_INPUT_TEXT_MESSAGE_SIZE", 32) + app, speech_service = _build_test_app() + + with TestClient(app) as client: + with client.websocket_connect("/v1/audio/speech/stream") as ws: + ws.send_json({"type": "session.config", "voice": "Vivian"}) + ws.send_json({"type": "input.text", "text": "x" * 128}) + + assert ws.receive_json() == { + "type": "error", + "message": "input.text message too large", + } + + ws.send_json({"type": "input.done"}) + assert ws.receive_json() == {"type": "session.done", "total_sentences": 0} + + assert speech_service._generate_audio_bytes.await_count == 0 + + def test_session_config_message_too_large(self, monkeypatch): + monkeypatch.setattr(streaming_speech_module, "_MAX_CONFIG_MESSAGE_SIZE", 64) + app, _ = _build_test_app() + + with TestClient(app) as client: + with client.websocket_connect("/v1/audio/speech/stream") as ws: + ws.send_json({"type": "session.config", "voice": "Vivian", "ref_audio": "x" * 512}) + + assert ws.receive_json() == { + "type": "error", + "message": "session.config message too large", + } + + def test_disconnect_aborts_streaming_request(self): + speech_service = MagicMock(spec=OmniOpenAIServingSpeech) + speech_service._generate_audio_bytes = AsyncMock(return_value=(b"", "audio/wav")) + speech_service._prepare_speech_generation = AsyncMock(return_value=("req-abort", object(), {})) + speech_service.engine_client = MagicMock() + speech_service.engine_client.abort = AsyncMock() + + async def mock_generate_pcm_chunks(_generator, _request_id): + yield b"\x01\x02" + + speech_service._generate_pcm_chunks = mock_generate_pcm_chunks + handler = OmniStreamingSpeechHandler(speech_service=speech_service) + + websocket = MagicMock() + websocket.send_json = AsyncMock(side_effect=[None, WebSocketDisconnect()]) + websocket.send_bytes = AsyncMock(side_effect=WebSocketDisconnect()) + + config = MagicMock() + config.model = None + config.voice = "Vivian" + config.task_type = None + config.language = None + config.instructions = None + config.response_format = "pcm" + config.speed = 1.0 + config.max_new_tokens = None + config.initial_codec_chunk_frames = None + config.ref_audio = None + config.ref_text = None + config.x_vector_only_mode = None + config.stream_audio = True + + with pytest.raises(WebSocketDisconnect): + asyncio.run(handler._generate_and_send(websocket, config, "Hello world.", 0)) + + speech_service.engine_client.abort.assert_awaited_once_with("req-abort") + assert websocket.send_json.await_count == 2 diff --git a/tests/entrypoints/openai_api/test_text_splitter.py b/tests/entrypoints/openai_api/test_text_splitter.py new file mode 100644 index 000000000..23d4d191f --- /dev/null +++ b/tests/entrypoints/openai_api/test_text_splitter.py @@ -0,0 +1,253 @@ +"""Tests for SentenceSplitter used in streaming TTS input.""" + +import pytest + +from vllm_omni.entrypoints.openai.text_splitter import SentenceSplitter + +pytestmark = [pytest.mark.openai, pytest.mark.speech] + + +class TestSentenceSplitterEnglish: + """Tests for English sentence splitting.""" + + def test_single_sentence_no_boundary(self): + splitter = SentenceSplitter() + result = splitter.add_text("Hello world") + assert result == [] + assert splitter.buffer == "Hello world" + + def test_single_sentence_with_boundary(self): + splitter = SentenceSplitter() + result = splitter.add_text("Hello world. How are you?") + assert len(result) == 1 + assert result[0] == "Hello world." + + def test_multiple_sentences(self): + splitter = SentenceSplitter() + result = splitter.add_text("Hello. How are you? I am fine! ") + assert len(result) == 3 + assert result[0] == "Hello." + assert result[1] == "How are you?" + assert result[2] == "I am fine!" + + def test_exclamation_mark(self): + splitter = SentenceSplitter() + result = splitter.add_text("Wow, that is great! Tell me more.") + assert len(result) == 1 + assert result[0] == "Wow, that is great!" + + def test_question_mark(self): + splitter = SentenceSplitter() + result = splitter.add_text("Can you hear me? I hope so.") + assert len(result) == 1 + assert result[0] == "Can you hear me?" + + +class TestSentenceSplitterChinese: + """Tests for CJK sentence splitting.""" + + def test_chinese_period(self): + splitter = SentenceSplitter() + result = splitter.add_text("你好世界。你好吗") + assert len(result) == 1 + assert result[0] == "你好世界。" + + def test_chinese_exclamation(self): + splitter = SentenceSplitter() + result = splitter.add_text("太好了!谢谢你") + assert len(result) == 1 + assert result[0] == "太好了!" + + def test_chinese_question(self): + splitter = SentenceSplitter() + result = splitter.add_text("你是谁?我是小明") + assert len(result) == 1 + assert result[0] == "你是谁?" + + def test_chinese_comma_no_split(self): + """Chinese commas are clause-level and should not trigger a split.""" + splitter = SentenceSplitter() + result = splitter.add_text("你好,世界") + assert result == [] + assert splitter.buffer == "你好,世界" + + def test_chinese_semicolon_no_split(self): + """Chinese semicolons are clause-level and should not trigger a split.""" + splitter = SentenceSplitter() + result = splitter.add_text("第一点;第二点") + assert result == [] + assert splitter.buffer == "第一点;第二点" + + def test_chinese_multiple(self): + splitter = SentenceSplitter() + result = splitter.add_text("你好!你好吗?我很好。") + assert len(result) == 3 + assert result[0] == "你好!" + assert result[1] == "你好吗?" + assert result[2] == "我很好。" + + +class TestSentenceSplitterMixed: + """Tests for mixed-language sentence splitting.""" + + def test_mixed_english_chinese(self): + splitter = SentenceSplitter() + result = splitter.add_text("Hello世界。How are you? ") + assert len(result) == 2 + assert result[0] == "Hello世界。" + assert result[1] == "How are you?" + + +class TestSentenceSplitterIncremental: + """Tests for incremental (multi-chunk) text input.""" + + def test_accumulation_across_chunks(self): + splitter = SentenceSplitter() + # First chunk: no boundary + result1 = splitter.add_text("Hello ") + assert result1 == [] + + # Second chunk: completes a sentence + result2 = splitter.add_text("world. How") + assert len(result2) == 1 + assert result2[0] == "Hello world." + assert splitter.buffer == "How" + + def test_word_by_word(self): + splitter = SentenceSplitter() + words = ["Hello, ", "how ", "are ", "you? ", "I ", "am ", "fine."] + all_sentences = [] + for word in words: + all_sentences.extend(splitter.add_text(word)) + + assert len(all_sentences) == 1 + assert all_sentences[0] == "Hello, how are you?" + # "I am fine." stays in buffer (no trailing whitespace after period) + + def test_three_chunks(self): + splitter = SentenceSplitter() + splitter.add_text("The quick brown ") + splitter.add_text("fox jumps. ") + result = splitter.add_text("Over the lazy dog. ") + # "The quick brown fox jumps." should have been returned on second chunk + # "Over the lazy dog." on third chunk + assert len(result) == 1 + assert result[0] == "Over the lazy dog." + + +class TestSentenceSplitterFlush: + """Tests for flush behavior.""" + + def test_flush_returns_remaining(self): + splitter = SentenceSplitter() + splitter.add_text("Hello world") + result = splitter.flush() + assert result == "Hello world" + assert splitter.buffer == "" + + def test_flush_empty_buffer(self): + splitter = SentenceSplitter() + result = splitter.flush() + assert result is None + + def test_flush_after_sentence(self): + splitter = SentenceSplitter() + splitter.add_text("Hello world. Remaining text") + result = splitter.flush() + assert result == "Remaining text" + + def test_flush_whitespace_only(self): + splitter = SentenceSplitter() + splitter.add_text("Hello. ") + # "Hello." extracted, buffer is " " + result = splitter.flush() + # Whitespace-only should return None + assert result is None + + def test_flush_clears_buffer(self): + splitter = SentenceSplitter() + splitter.add_text("some text") + splitter.flush() + assert splitter.buffer == "" + # Second flush should return None + assert splitter.flush() is None + + +class TestSentenceSplitterEdgeCases: + """Edge case tests.""" + + def test_empty_input(self): + splitter = SentenceSplitter() + result = splitter.add_text("") + assert result == [] + assert splitter.buffer == "" + + def test_none_like_empty(self): + """Empty string should not affect buffer.""" + splitter = SentenceSplitter() + splitter.add_text("Hello") + splitter.add_text("") + assert splitter.buffer == "Hello" + + def test_only_punctuation(self): + splitter = SentenceSplitter() + result = splitter.add_text(". ") + # "." is 1 char, below default min_sentence_length of 2 + # It will be carried forward + assert result == [] + + def test_min_sentence_length(self): + splitter = SentenceSplitter(min_sentence_length=10) + result = splitter.add_text("Hi. Hello world. ") + # "Hi." is 3 chars (< 10), so it gets carried to "Hello world." + assert len(result) == 1 + assert "Hi." in result[0] + assert "Hello world." in result[0] + + def test_short_segments_are_carried_until_long_enough(self): + splitter = SentenceSplitter(min_sentence_length=10) + result = splitter.add_text("Hi. Ok. Hello there. ") + assert result == ["Hi.Ok.Hello there."] + assert splitter.buffer == "" + + def test_min_sentence_length_zero(self): + splitter = SentenceSplitter(min_sentence_length=0) + result = splitter.add_text("A. B. ") + assert len(result) == 2 + + def test_no_boundary_then_flush(self): + splitter = SentenceSplitter() + result = splitter.add_text("Hello world how are you") + assert result == [] + flushed = splitter.flush() + assert flushed == "Hello world how are you" + + def test_consecutive_punctuation(self): + splitter = SentenceSplitter() + result = splitter.add_text("Really?! Yes, really. ") + assert len(result) >= 1 + + def test_reuse_after_flush(self): + """Splitter can be reused after flush.""" + splitter = SentenceSplitter() + splitter.add_text("First session.") + splitter.flush() + + result = splitter.add_text("Second session. More text") + assert len(result) == 1 + assert result[0] == "Second session." + assert splitter.buffer == "More text" + + +class TestSentenceSplitterBufferLimit: + """Tests for buffer overflow protection.""" + + def test_buffer_overflow_raises(self): + from vllm_omni.entrypoints.openai.text_splitter import _MAX_BUFFER_SIZE + + splitter = SentenceSplitter() + # Fill buffer just under the limit + splitter.add_text("x" * (_MAX_BUFFER_SIZE - 1)) + # One more char should exceed the limit + with pytest.raises(ValueError, match="exceeded maximum size"): + splitter.add_text("xx") diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py index 98c9752d2..1507ce8c3 100644 --- a/vllm_omni/entrypoints/openai/api_server.py +++ b/vllm_omni/entrypoints/openai/api_server.py @@ -19,10 +19,9 @@ from typing import Annotated, Any, cast import httpx import vllm.envs as envs -from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile +from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, WebSocket from fastapi.responses import JSONResponse, StreamingResponse from PIL import Image -from pydantic import BaseModel, Field from starlette.datastructures import State from starlette.routing import Route from vllm import SamplingParams @@ -99,6 +98,7 @@ from vllm_omni.entrypoints.openai.protocol.videos import ( ) from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech +from vllm_omni.entrypoints.openai.serving_speech_stream import OmniStreamingSpeechHandler from vllm_omni.entrypoints.openai.serving_video import OmniOpenAIServingVideo from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniSamplingParams, OmniTextPrompt from vllm_omni.lora.request import LoRARequest @@ -106,30 +106,6 @@ from vllm_omni.lora.utils import stable_lora_int_id logger = init_logger(__name__) router = APIRouter() -profiler_router = APIRouter() - - -def _should_enable_profiler_endpoints(args: Namespace) -> bool: - # Check upstream vLLM's profiler_config - profiler_config = getattr(args, "profiler_config", None) - if profiler_config is not None: - # profiler_config exists, check if profiler is set - profiler = getattr(profiler_config, "profiler", None) - if profiler is not None: - return True - - # TODO: remove this env after refactoring torch profiler to CLI args - env_value = os.environ.get("VLLM_TORCH_PROFILER_DIR") - return env_value is not None - - -class ProfileRequest(BaseModel): - """Request model for profiling endpoints.""" - - stages: list[int] | None = Field( - default=None, - description="List of stage IDs to profile. If None, profiles all stages.", - ) def _remove_route_from_router( @@ -259,11 +235,6 @@ async def omni_run_server_worker(listen_address, sock, args, client_config=None, await omni_init_app_state(engine_client, app.state, args) - # Conditionally register profiler endpoints based on config or env var - if _should_enable_profiler_endpoints(args): - logger.warning("Profiler endpoints are enabled. This should ONLY be used for local development!") - app.include_router(profiler_router) - vllm_config = await engine_client.get_vllm_config() # Check if pure diffusion mode (vllm_config will be None) @@ -294,7 +265,6 @@ async def omni_run_server_worker(listen_address, sock, args, client_config=None, ssl_certfile=args.ssl_certfile, ssl_ca_certs=args.ssl_ca_certs, ssl_cert_reqs=args.ssl_cert_reqs, - ssl_ciphers=args.ssl_ciphers, h11_max_incomplete_event_size=args.h11_max_incomplete_event_size, h11_max_header_count=args.h11_max_header_count, **uvicorn_kwargs, @@ -742,6 +712,10 @@ async def omni_init_app_state( engine_client, state.openai_serving_models, request_logger=request_logger ) + state.openai_streaming_speech = OmniStreamingSpeechHandler( + speech_service=state.openai_serving_speech, + ) + state.openai_serving_video = OmniOpenAIServingVideo( engine_client, model_name=served_model_names[0] if served_model_names else None, @@ -889,113 +863,29 @@ async def list_voices(raw_request: Request): if handler is None: return base(raw_request).create_error_response(message="The model does not support Speech API") - # Get all speakers (both model built-in and uploaded) speakers = sorted(handler.supported_speakers) if handler.supported_speakers else [] - - # Get uploaded speakers details - uploaded_speakers = [] - if hasattr(handler, "uploaded_speakers"): - for voice_name, info in handler.uploaded_speakers.items(): - uploaded_speakers.append( - { - "name": info.get("name", voice_name), - "consent": info.get("consent", ""), - "created_at": info.get("created_at", 0), - "file_size": info.get("file_size", 0), - "mime_type": info.get("mime_type", ""), - } - ) - - return JSONResponse(content={"voices": speakers, "uploaded_voices": uploaded_speakers}) + return JSONResponse(content={"voices": speakers}) -@router.post( - "/v1/audio/voices", - responses={ - HTTPStatus.OK.value: {"model": dict}, - HTTPStatus.BAD_REQUEST.value: {"model": ErrorResponse}, - HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse}, - }, -) -async def upload_voice( - raw_request: Request, - audio_sample: UploadFile = File(...), - consent: str = Form(...), - name: str = Form(...), -): - """Upload a new voice sample for voice cloning. +@router.websocket("/v1/audio/speech/stream") +async def streaming_speech(websocket: WebSocket): + """WebSocket endpoint for streaming text input TTS. - Uploads an audio file that can be used as a reference for voice cloning - in Base task TTS requests. The voice can then be referenced by name - in subsequent TTS requests. - - Args: - audio_sample: Audio file (max 10MB) - consent: Consent recording ID - name: Name for the new voice - raw_request: Raw FastAPI request - - Returns: - JSON response with voice information + Accepts text incrementally, splits at sentence boundaries, and + returns audio per sentence. See serving_speech_stream.py for protocol. """ - handler = Omnispeech(raw_request) + handler = getattr(websocket.app.state, "openai_streaming_speech", None) if handler is None: - return base(raw_request).create_error_response(message="The model does not support Speech API") - - try: - # Upload the voice - result = await handler.upload_voice(audio_sample, consent, name) - - return JSONResponse(content={"success": True, "voice": result}) - - except ValueError as e: - return base(raw_request).create_error_response(message=str(e)) - except Exception as e: - logger.exception(f"Failed to upload voice: {e}") - return base(raw_request).create_error_response(message=f"Failed to upload voice: {str(e)}") - - -@router.delete( - "/v1/audio/voices/{name}", - responses={ - HTTPStatus.OK.value: {"model": dict}, - HTTPStatus.NOT_FOUND.value: {"model": ErrorResponse}, - HTTPStatus.INTERNAL_SERVER_ERROR.value: {"model": ErrorResponse}, - }, -) -async def delete_voice(name: str, raw_request: Request): - """Delete an uploaded voice. - - Deletes the voice sample and associated metadata. Also removes any - cached voice clone prompts for this voice. - - Args: - name: Name of the voice to delete - raw_request: Raw FastAPI request - - Returns: - JSON response indicating success or failure - """ - handler = Omnispeech(raw_request) - if handler is None: - return base(raw_request).create_error_response(message="The model does not support Speech API") - - try: - # Delete the voice - success = await handler.delete_voice(name) - if not success: - return JSONResponse( - content={"success": False, "error": f"Voice '{name}' not found"}, - status_code=HTTPStatus.NOT_FOUND.value, - ) - - return JSONResponse(content={"success": True, "message": f"Voice '{name}' deleted successfully"}) - - except ValueError as e: - return base(raw_request).create_error_response(message=str(e)) - except Exception as e: - logger.exception(f"Failed to delete voice '{name}': {e}") - return base(raw_request).create_error_response(message=f"Failed to delete voice: {str(e)}") + await websocket.accept() + await websocket.send_json( + { + "type": "error", + "message": "Streaming speech is not available", + } + ) + await websocket.close() + return + await handler.handle_session(websocket) # Health and Model endpoints for diffusion mode @@ -1637,58 +1527,6 @@ def apply_stage_default_sampling_params( setattr(sampling_params, param_name, param_value) -@profiler_router.post("/start_profile") -async def start_profile(raw_request: Request, request: ProfileRequest | None = None): - """Start profiling for the engine. - - Args: - request: Optional request body with stages to profile. - - stages: List of stage IDs to profile. If None, profiles all stages. - - Example: - POST /start_profile - {"stages": [0, 1]} # Profile only stages 0 and 1 - """ - try: - stages = request.stages if request else None - logger.info("Starting profiler for stages: %s", stages if stages else "all") - engine_client = raw_request.app.state.engine_client - result = await engine_client.start_profile(stages=stages) - logger.info("Profiler started.") - return JSONResponse(content=result) - except Exception as e: - logger.exception("Failed to start profiler: %s", e) - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value, detail=f"Failed to start profiler: {str(e)}" - ) - - -@profiler_router.post("/stop_profile") -async def stop_profile(raw_request: Request, request: ProfileRequest | None = None): - """Stop profiling for the engine. - - Args: - request: Optional request body with stages to stop profiling. - - stages: List of stage IDs to stop profiling. If None, stops all stages. - - Example: - POST /stop_profile - {"stages": [0, 1]} # Stop profiling only stages 0 and 1 - """ - try: - stages = request.stages if request else None - logger.info("Stopping profiler for stages: %s", stages if stages else "all") - engine_client = raw_request.app.state.engine_client - result = await engine_client.stop_profile(stages=stages) - logger.info("Profiler stopped.") - return JSONResponse(content=result) - except Exception as e: - logger.exception("Failed to stop profiler: %s", e) - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR.value, detail=f"Failed to stop profiler: {str(e)}" - ) - - async def _run_video_generation( request: VideoGenerationRequest, raw_request: Request, diff --git a/vllm_omni/entrypoints/openai/protocol/audio.py b/vllm_omni/entrypoints/openai/protocol/audio.py index 871787173..eb32d7f05 100644 --- a/vllm_omni/entrypoints/openai/protocol/audio.py +++ b/vllm_omni/entrypoints/openai/protocol/audio.py @@ -100,3 +100,52 @@ class CreateAudio(BaseModel): class AudioResponse(BaseModel): audio_data: bytes | str media_type: str + + +class StreamingSpeechSessionConfig(BaseModel): + """Configuration sent as the first WebSocket message for streaming TTS.""" + + model: str | None = None + voice: str | None = None + task_type: Literal["CustomVoice", "VoiceDesign", "Base"] | None = None + language: str | None = None + instructions: str | None = None + response_format: Literal["wav", "pcm", "flac", "mp3", "aac", "opus"] = "wav" + speed: float | None = Field(default=1.0, ge=0.25, le=4.0) + max_new_tokens: int | None = Field(default=None, ge=1) + initial_codec_chunk_frames: int | None = Field( + default=None, + ge=0, + description="Initial chunk size for reduced TTFA. Overrides stage config for this session.", + ) + ref_audio: str | None = None + ref_text: str | None = None + x_vector_only_mode: bool | None = None + stream_audio: bool = Field( + default=False, + description=( + "If true, send raw PCM audio chunks progressively over WebSocket. " + "Requires response_format='pcm'. Speed adjustment is not supported when streaming." + ), + ) + split_granularity: Literal["sentence", "clause"] = Field( + default="sentence", + description=( + "Text splitting granularity: 'sentence' splits on .!?。!?, " + "'clause' also splits on CJK commas , and semicolons ;." + ), + ) + + @model_validator(mode="after") + def validate_streaming_constraints(self) -> "StreamingSpeechSessionConfig": + if self.stream_audio: + if self.response_format != "pcm": + raise ValueError( + "WebSocket streaming audio (stream_audio=true) requires response_format='pcm'. " + f"Got response_format='{self.response_format}'." + ) + if self.speed is None: + self.speed = 1.0 + elif self.speed != 1.0: + raise ValueError("Speed adjustment is not supported when stream_audio=true. Set speed=1.0 or omit it.") + return self diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index 7bcf75ace..6b5656474 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -20,6 +20,7 @@ from vllm.utils import random_uuid from vllm_omni.entrypoints.openai.audio_utils_mixin import AudioMixin from vllm_omni.entrypoints.openai.metadata_manager import MetadataManager from vllm_omni.entrypoints.openai.protocol.audio import ( + AudioResponse, CreateAudio, OpenAICreateSpeechRequest, ) @@ -636,6 +637,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): raise except Exception as e: logger.exception("Streaming speech generation failed for %s: %s", request_id, e) + raise @staticmethod def _extract_audio_output(res) -> tuple[dict | None, str | None]: @@ -650,7 +652,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): mm = getattr(ro, "multimodal_output", None) if ro else None if not mm: return None, None - key = "audio" if "audio" in mm else None + key = "audio" if "audio" in mm else ("model_outputs" if "model_outputs" in mm else None) return mm, key def _build_tts_params(self, request: OpenAICreateSpeechRequest) -> dict[str, Any]: @@ -721,6 +723,95 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): return params + async def _prepare_speech_generation( + self, + request: OpenAICreateSpeechRequest, + ) -> tuple[str, Any, dict[str, Any]]: + if self.engine_client.errored: + raise self.engine_client.dead_error + + if self._is_tts: + validation_error = self._validate_tts_request(request) + if validation_error: + raise ValueError(validation_error) + + tts_params = self._build_tts_params(request) + if request.ref_audio is not None: + wav_list, sr = await self._resolve_ref_audio(request.ref_audio) + tts_params["ref_audio"] = [[wav_list, sr]] + + ph_len = self._estimate_prompt_len(tts_params) + prompt = {"prompt_token_ids": [1] * ph_len, "additional_information": tts_params} + else: + tts_params = {} + prompt = {"prompt": request.input} + + request_id = f"speech-{random_uuid()}" + logger.info( + "TTS speech request %s: text=%r, task_type=%s", + request_id, + request.input[:50] + "..." if len(request.input) > 50 else request.input, + tts_params.get("task_type", ["unknown"])[0], + ) + + sampling_params_list = self.engine_client.default_sampling_params_list + generator = self.engine_client.generate( + prompt=prompt, + request_id=request_id, + sampling_params_list=sampling_params_list, + output_modalities=["audio"], + ) + return request_id, generator, tts_params + + async def _iter_pcm_audio_bytes(self, request: OpenAICreateSpeechRequest): + """Yield raw PCM bytes for a speech request as soon as chunks are decoded.""" + request_id, generator, _ = await self._prepare_speech_generation(request) + async for chunk in self._generate_pcm_chunks(generator, request_id): + yield chunk + + async def _generate_audio_bytes( + self, + request: OpenAICreateSpeechRequest, + ) -> tuple[bytes, str]: + request_id, generator, _ = await self._prepare_speech_generation(request) + + final_output: OmniRequestOutput | None = None + async for res in generator: + final_output = res + + if final_output is None: + raise ValueError("No output generated from the model.") + + audio_output, audio_key = self._extract_audio_output(final_output) + if audio_key is None: + raise ValueError("TTS model did not produce audio output.") + + audio_tensor = audio_output[audio_key] + sr_raw = audio_output.get("sr", 24000) + sr_val = sr_raw[-1] if isinstance(sr_raw, list) and sr_raw else sr_raw + sample_rate = sr_val.item() if hasattr(sr_val, "item") else int(sr_val) + + if isinstance(audio_tensor, list): + import torch + + audio_tensor = torch.cat(audio_tensor, dim=-1) + if hasattr(audio_tensor, "float"): + audio_tensor = audio_tensor.float().detach().cpu().numpy() + + if audio_tensor.ndim > 1: + audio_tensor = audio_tensor.squeeze() + + audio_obj = CreateAudio( + audio_tensor=audio_tensor, + sample_rate=sample_rate, + response_format=request.response_format or "wav", + speed=request.speed or 1.0, + stream_format=request.stream_format, + base64_encode=False, + ) + audio_response: AudioResponse = self.create_audio(audio_obj) + return audio_response.audio_data, audio_response.media_type + async def create_speech( self, request: OpenAICreateSpeechRequest, @@ -750,89 +841,16 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): logger.error("Error with model %s", error_check_ret) return error_check_ret - if self.engine_client.errored: - raise self.engine_client.dead_error - - request_id = f"speech-{random_uuid()}" - try: - if self._is_tts: - # Validate TTS parameters - validation_error = self._validate_tts_request(request) - if validation_error: - return self.create_error_response(validation_error) - - tts_params = self._build_tts_params(request) - if request.ref_audio is not None: - wav_list, sr = await self._resolve_ref_audio(request.ref_audio) - tts_params["ref_audio"] = [[wav_list, sr]] - - # Prompt length must match model-side embeddings; values are placeholders. - ph_len = self._estimate_prompt_len(tts_params) - prompt = {"prompt_token_ids": [1] * ph_len, "additional_information": tts_params} - else: - tts_params = {} - prompt = {"prompt": request.input} - - logger.info( - "TTS speech request %s: text=%r, task_type=%s", - request_id, - request.input[:50] + "..." if len(request.input) > 50 else request.input, - tts_params.get("task_type", ["unknown"])[0], - ) - - sampling_params_list = self.engine_client.default_sampling_params_list - - generator = self.engine_client.generate( - prompt=prompt, - request_id=request_id, - sampling_params_list=sampling_params_list, - output_modalities=["audio"], - ) - if request.stream: + request_id, generator, _ = await self._prepare_speech_generation(request) return StreamingResponse( self._generate_pcm_chunks(generator, request_id), media_type="audio/pcm", ) - # Non-streaming: collect final output - final_output: OmniRequestOutput | None = None - async for res in generator: - final_output = res - - if final_output is None: - return self.create_error_response("No output generated from the model.") - - audio_output, audio_key = self._extract_audio_output(final_output) - if audio_key is None: - return self.create_error_response("TTS model did not produce audio output.") - - audio_tensor = audio_output[audio_key] - sr_raw = audio_output.get("sr", 24000) - sr_val = sr_raw[-1] if isinstance(sr_raw, list) and sr_raw else sr_raw - sample_rate = sr_val.item() if hasattr(sr_val, "item") else int(sr_val) - - # async_chunk mode accumulates chunks as a list; concat first. - if isinstance(audio_tensor, list): - import torch - - audio_tensor = torch.cat(audio_tensor, dim=-1) - if hasattr(audio_tensor, "float"): - audio_tensor = audio_tensor.float().detach().cpu().numpy() - if audio_tensor.ndim > 1: - audio_tensor = audio_tensor.squeeze() - - audio_obj = CreateAudio( - audio_tensor=audio_tensor, - sample_rate=sample_rate, - response_format=request.response_format or "wav", - speed=request.speed or 1.0, - stream_format=request.stream_format, - base64_encode=False, - ) - audio_response = self.create_audio(audio_obj) - return Response(content=audio_response.audio_data, media_type=audio_response.media_type) + audio_bytes, media_type = await self._generate_audio_bytes(request) + return Response(content=audio_bytes, media_type=media_type) except asyncio.CancelledError: return self.create_error_response("Client disconnected") diff --git a/vllm_omni/entrypoints/openai/serving_speech_stream.py b/vllm_omni/entrypoints/openai/serving_speech_stream.py new file mode 100644 index 000000000..d88c88b65 --- /dev/null +++ b/vllm_omni/entrypoints/openai/serving_speech_stream.py @@ -0,0 +1,289 @@ +"""WebSocket handler for streaming text input TTS. + +Accepts text incrementally via WebSocket, buffers and splits at sentence +boundaries, and generates audio per sentence using the existing TTS pipeline. + +Protocol: + Client -> Server: + {"type": "session.config", ...} # Session config (sent once first) + {"type": "input.text", "text": "..."} # Text chunks + {"type": "input.done"} # End of input + + Server -> Client: + {"type": "audio.start", "sentence_index": 0, "sentence_text": "...", "format": "wav"} + + {"type": "audio.done", "sentence_index": 0} + {"type": "session.done", "total_sentences": N} + {"type": "error", "message": "..."} +""" + +import asyncio +import json +from contextlib import aclosing + +from fastapi import WebSocket, WebSocketDisconnect +from pydantic import ValidationError +from vllm.logger import init_logger + +from vllm_omni.entrypoints.openai.protocol.audio import ( + OpenAICreateSpeechRequest, + StreamingSpeechSessionConfig, +) +from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech +from vllm_omni.entrypoints.openai.text_splitter import ( + SPLIT_CLAUSE, + SPLIT_SENTENCE, + SentenceSplitter, +) + +logger = init_logger(__name__) + +_DEFAULT_IDLE_TIMEOUT = 30.0 # seconds +_DEFAULT_CONFIG_TIMEOUT = 10.0 # seconds +_PCM_SAMPLE_RATE = 24000 +_MAX_CONFIG_MESSAGE_SIZE = 4 * 1024 * 1024 # allow large ref_audio payloads +_MAX_INPUT_TEXT_MESSAGE_SIZE = 128 * 1024 + + +class OmniStreamingSpeechHandler: + """Handles WebSocket sessions for streaming text-input TTS. + + Each WebSocket connection is an independent session. Text arrives + incrementally, is split at sentence boundaries, and audio is generated + per sentence using the existing OmniOpenAIServingSpeech pipeline. + + Args: + speech_service: The existing TTS serving instance (reused for + validation and audio generation). + idle_timeout: Max seconds to wait for a message before closing. + config_timeout: Max seconds to wait for the initial session.config. + """ + + def __init__( + self, + speech_service: OmniOpenAIServingSpeech, + idle_timeout: float = _DEFAULT_IDLE_TIMEOUT, + config_timeout: float = _DEFAULT_CONFIG_TIMEOUT, + ) -> None: + self._speech_service = speech_service + self._idle_timeout = idle_timeout + self._config_timeout = config_timeout + + async def handle_session(self, websocket: WebSocket) -> None: + """Main session loop for a single WebSocket connection.""" + await websocket.accept() + + try: + # 1. Wait for session.config + config = await self._receive_config(websocket) + if config is None: + return # Error already sent, connection closing + + # Validate model if specified + if config.model and hasattr(self._speech_service, "_check_model"): + error = await self._speech_service._check_model( + OpenAICreateSpeechRequest(input="ping", model=config.model) + ) + if error is not None: + await self._send_error(websocket, str(error)) + return + + boundary_re = SPLIT_CLAUSE if config.split_granularity == "clause" else SPLIT_SENTENCE + splitter = SentenceSplitter(boundary_re=boundary_re) + sentence_index = 0 + + # 2. Receive text chunks until input.done + while True: + try: + raw = await asyncio.wait_for( + websocket.receive_text(), + timeout=self._idle_timeout, + ) + except asyncio.TimeoutError: + await self._send_error(websocket, "Idle timeout: no message received") + return + + if len(raw) > _MAX_INPUT_TEXT_MESSAGE_SIZE: + await self._send_error(websocket, "input.text message too large") + continue + + try: + msg = json.loads(raw) + except json.JSONDecodeError: + await self._send_error(websocket, "Invalid JSON message") + continue + + if not isinstance(msg, dict): + await self._send_error(websocket, "WebSocket messages must be JSON objects") + continue + + msg_type = msg.get("type") + + if msg_type == "input.text": + text = msg.get("text", "") + if not isinstance(text, str): + await self._send_error(websocket, "input.text requires a string value") + continue + sentences = splitter.add_text(text) + for sentence in sentences: + await self._generate_and_send(websocket, config, sentence, sentence_index) + sentence_index += 1 + + elif msg_type == "input.done": + # Flush remaining buffer + remaining = splitter.flush() + if remaining: + await self._generate_and_send(websocket, config, remaining, sentence_index) + sentence_index += 1 + + # Send session.done + await websocket.send_json( + { + "type": "session.done", + "total_sentences": sentence_index, + } + ) + return + + else: + await self._send_error( + websocket, + f"Unknown message type: {msg_type}", + ) + + except WebSocketDisconnect: + logger.info("Streaming speech: client disconnected") + except Exception as e: + logger.exception("Streaming speech session error: %s", e) + try: + await self._send_error(websocket, f"Internal error: {e}") + except Exception: + logger.debug("Failed to send error to streaming speech client", exc_info=True) + + async def _receive_config(self, websocket: WebSocket) -> StreamingSpeechSessionConfig | None: + """Wait for and validate the session.config message.""" + try: + raw = await asyncio.wait_for( + websocket.receive_text(), + timeout=self._config_timeout, + ) + except asyncio.TimeoutError: + await self._send_error(websocket, "Timeout waiting for session.config") + return None + + if len(raw) > _MAX_CONFIG_MESSAGE_SIZE: + await self._send_error(websocket, "session.config message too large") + return None + + try: + msg = json.loads(raw) + except json.JSONDecodeError: + await self._send_error(websocket, "Invalid JSON in session.config") + return None + + if not isinstance(msg, dict): + await self._send_error(websocket, "session.config must be a JSON object") + return None + + if msg.get("type") != "session.config": + await self._send_error( + websocket, + f"Expected session.config, got: {msg.get('type')}", + ) + return None + + try: + config = StreamingSpeechSessionConfig(**{k: v for k, v in msg.items() if k != "type"}) + except ValidationError as e: + await self._send_error(websocket, f"Invalid session config: {e}") + return None + + return config + + async def _generate_and_send( + self, + websocket: WebSocket, + config: StreamingSpeechSessionConfig, + sentence_text: str, + sentence_index: int, + ) -> None: + """Generate audio for a single sentence and send it over WebSocket.""" + response_format = config.response_format or "wav" + + request = OpenAICreateSpeechRequest( + input=sentence_text, + model=config.model, + voice=config.voice, + task_type=config.task_type, + language=config.language, + instructions=config.instructions, + response_format=response_format, + speed=config.speed, + max_new_tokens=config.max_new_tokens, + initial_codec_chunk_frames=config.initial_codec_chunk_frames, + ref_audio=config.ref_audio, + ref_text=config.ref_text, + x_vector_only_mode=config.x_vector_only_mode, + stream=config.stream_audio, + ) + + start_payload = { + "type": "audio.start", + "sentence_index": sentence_index, + "sentence_text": sentence_text, + "format": response_format, + } + if config.stream_audio and response_format == "pcm": + start_payload["sample_rate"] = _PCM_SAMPLE_RATE + await websocket.send_json(start_payload) + + total_bytes = 0 + generation_failed = False + request_id = None + try: + if config.stream_audio: + request_id, generator, _ = await self._speech_service._prepare_speech_generation(request) + async with aclosing(self._speech_service._generate_pcm_chunks(generator, request_id)) as stream: + async for chunk in stream: + total_bytes += len(chunk) + await websocket.send_bytes(chunk) + else: + audio_bytes, _ = await self._speech_service._generate_audio_bytes(request) + total_bytes = len(audio_bytes) + await websocket.send_bytes(audio_bytes) + except WebSocketDisconnect: + if request_id is not None: + try: + await self._speech_service.engine_client.abort(request_id) + except Exception: + logger.debug("Failed to abort streaming speech request %s", request_id, exc_info=True) + raise + except Exception as e: + generation_failed = True + logger.error("Generation failed for sentence %d: %s", sentence_index, e) + await self._send_error(websocket, f"Generation failed for sentence {sentence_index}: {e}") + finally: + try: + await websocket.send_json( + { + "type": "audio.done", + "sentence_index": sentence_index, + "total_bytes": total_bytes, + "error": generation_failed, + } + ) + except Exception: + logger.debug("Failed to send audio.done for sentence %d", sentence_index, exc_info=True) + + @staticmethod + async def _send_error(websocket: WebSocket, message: str) -> None: + """Send an error message to the client.""" + try: + await websocket.send_json( + { + "type": "error", + "message": message, + } + ) + except Exception: + pass # Connection may already be closed; safe to ignore diff --git a/vllm_omni/entrypoints/openai/text_splitter.py b/vllm_omni/entrypoints/openai/text_splitter.py new file mode 100644 index 000000000..9c02de139 --- /dev/null +++ b/vllm_omni/entrypoints/openai/text_splitter.py @@ -0,0 +1,120 @@ +"""Multi-language sentence boundary detector for streaming TTS input. + +Buffers incoming text and splits at sentence boundaries (English and CJK), +yielding complete sentences for audio generation. +""" + +import re +from re import Pattern + +# Maximum buffer size (in characters) to prevent unbounded memory growth. +_MAX_BUFFER_SIZE = 100_000 # ~100 KB of text + +# Sentence-level: .!? + CJK sentence-ending 。!? +# NOTE: English requires trailing whitespace to confirm a boundary — +# end-of-string is NOT treated as a boundary (that is what flush() is for). +SPLIT_SENTENCE = re.compile( + r"(?<=[.!?])\s+" + r"|(?<=[。!?])" +) + +# Clause-level: adds CJK commas , and semicolons ; +SPLIT_CLAUSE = re.compile( + r"(?<=[.!?])\s+" + r"|(?<=[。!?,;])" +) + +# Default alias +_SENTENCE_BOUNDARY_RE = SPLIT_SENTENCE + + +class SentenceSplitter: + """Incremental sentence splitter for streaming text input. + + Buffers text and yields complete sentences when boundaries are detected. + Designed for TTS pipelines where text arrives incrementally (e.g., from STT). + + Args: + min_sentence_length: Minimum character length for a sentence. + Sentences shorter than this are kept in the buffer to avoid + splitting on abbreviations like "Dr." or "U.S.". + boundary_re: Custom compiled regex for sentence boundaries. + Use ``SPLIT_SENTENCE`` (default) for sentence-level splitting, + ``SPLIT_CLAUSE`` for finer-grained clause-level splitting, + or pass your own ``re.Pattern``. + """ + + def __init__( + self, + min_sentence_length: int = 2, + boundary_re: Pattern[str] | None = None, + ) -> None: + self._buffer: str = "" + self._min_sentence_length = min_sentence_length + self._boundary_re = boundary_re or _SENTENCE_BOUNDARY_RE + + @property + def buffer(self) -> str: + """Current buffered text.""" + return self._buffer + + def add_text(self, text: str) -> list[str]: + """Add text to the buffer and return any complete sentences. + + Args: + text: Incoming text chunk. + + Returns: + List of complete sentences extracted from the buffer. + May be empty if no sentence boundary was found. + + Raises: + ValueError: If the buffer exceeds the maximum size. + """ + if not text: + return [] + + self._buffer += text + if len(self._buffer) > _MAX_BUFFER_SIZE: + raise ValueError( + f"Text buffer exceeded maximum size ({_MAX_BUFFER_SIZE} chars). " + "Consider adding sentence-ending punctuation to your input." + ) + return self._extract_sentences() + + def flush(self) -> str | None: + """Flush remaining buffered text as a final sentence. + + Returns: + The remaining buffered text (stripped), or None if buffer is empty. + """ + remaining = self._buffer.strip() + self._buffer = "" + return remaining if remaining else None + + def _extract_sentences(self) -> list[str]: + """Split buffer at sentence boundaries, keeping incomplete text buffered.""" + parts = self._boundary_re.split(self._buffer) + + if len(parts) <= 1: + # No boundary found — keep everything in buffer + return [] + + sentences: list[str] = [] + carry = "" + # All parts except the last are complete sentences + for i in range(len(parts) - 1): + text = carry + parts[i] + carry = "" + stripped = text.strip() + if len(stripped) >= self._min_sentence_length: + sentences.append(stripped) + elif stripped: + # Too short (e.g. "Dr.") — carry forward to next part + carry = text + # else: empty, skip + + # Last part stays in buffer (may be incomplete) + self._buffer = carry + parts[-1] + + return sentences From c501c2d5128e1eafe376277878e1e5b1e0bb47fd Mon Sep 17 00:00:00 2001 From: wangyu <53896905+yenuo26@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:08:11 +0800 Subject: [PATCH 18/38] [Test] Nightly Buildkite Pytest Test Case Statistics And Send HTML Report By Email (#1674) Signed-off-by: yenuo26 <410167048@qq.com> Signed-off-by: wangyu <53896905+yenuo26@users.noreply.github.com> --- .buildkite/test-nightly.yml | 12 +- .../nightly/buildkite_testcase_statistics.py | 798 ++++++++++++++++++ tools/nightly/send_nightly_email.py | 351 ++++++++ tools/nightly/send_nightly_perf_email.py | 234 ----- 4 files changed, 1157 insertions(+), 238 deletions(-) create mode 100644 tools/nightly/buildkite_testcase_statistics.py create mode 100644 tools/nightly/send_nightly_email.py delete mode 100644 tools/nightly/send_nightly_perf_email.py diff --git a/.buildkite/test-nightly.yml b/.buildkite/test-nightly.yml index 013640988..7c8c4a89e 100644 --- a/.buildkite/test-nightly.yml +++ b/.buildkite/test-nightly.yml @@ -119,7 +119,7 @@ steps: volumes: - "/fsx/hf_cache:/fsx/hf_cache" - - label: ":full_moon: Omni Model Perf Test with H100" + - label: ":full_moon: Omni Model Perf Test & Test Case Statistics" key: nightly-performance timeout_in_minutes: 180 depends_on: upload-nightly-pipeline @@ -129,6 +129,8 @@ steps: - export BENCHMARK_DIR=tests - pytest -s -v tests/perf/scripts/run_benchmark.py - buildkite-agent artifact upload "tests/*.json" + - python tools/nightly/buildkite_testcase_statistics.py -o tests/buildkite_testcase_statistics.html + - buildkite-agent artifact upload "tests/*.html" agents: queue: "mithril-h100-pool" plugins: @@ -158,17 +160,19 @@ steps: path: /mnt/hf-cache type: DirectoryOrCreate - - label: ":email: Nightly Perf Collection & Email" + - label: ":email: Nightly Collection & Email" key: nightly-perf-distribution - depends_on: nightly-performance + depends_on: + - nightly-performance if: build.env("NIGHTLY") == "1" commands: - pip install openpyxl - export DEFAULT_INPUT_DIR=tests - export DEFAULT_OUTPUT_DIR=tests - buildkite-agent artifact download "tests/*.json" . --step nightly-performance + - buildkite-agent artifact download "tests/*.html" . --step nightly-performance - python tools/nightly/generate_nightly_perf_excel.py - - python tools/nightly/send_nightly_perf_email.py + - python tools/nightly/send_nightly_email.py --report-file "tests/*.xlsx, tests/*.html" - buildkite-agent artifact upload "tests/*.xlsx" agents: queue: "cpu_queue_premerge" diff --git a/tools/nightly/buildkite_testcase_statistics.py b/tools/nightly/buildkite_testcase_statistics.py new file mode 100644 index 000000000..312bd6b6b --- /dev/null +++ b/tools/nightly/buildkite_testcase_statistics.py @@ -0,0 +1,798 @@ +#!/usr/bin/env python3 +""" +Parse pytest commands from Buildkite test-ready.yml, test-merge.yml, test-nightly.yml; +collect test cases (including parametrized) via pytest --collect-only -q and produce an HTML report. + +Usage (run from repo root): + python scripts/buildkite_testcase_statistics.py -o buildkite_testcase_statistics.html + +Requires: PyYAML (pip install pyyaml) +""" + +from __future__ import annotations + +import argparse +import ast +import html +import re +import subprocess +import sys +from pathlib import Path + +import yaml + +# Repo root (parent of the directory containing this script) +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +BUILDKITE_DIR = REPO_ROOT / ".buildkite" +PIPELINE_FILES = ["test-ready.yml", "test-merge.yml", "test-nightly.yml"] + + +def load_yaml(path: Path) -> dict: + with open(path, encoding="utf-8") as f: + return yaml.safe_load(f) + + +PYTEST_CMD_RE = re.compile(r"(?:timeout\s+\S+\s+)?(?:python3? -m\s+)?pytest\s+[^\n&|;]*") + + +def normalize_commands(step: dict) -> list[str]: + raw = step.get("commands") or [] + if isinstance(raw, str): + raw = [raw] + text = "\n".join((c.strip() if isinstance(c, str) else "") for c in raw if c) + text = text.replace("$$", "$") + result = [] + for m in PYTEST_CMD_RE.finditer(text): + line = m.group(0).strip() + line_start = text.rfind("\n", 0, m.start()) + 1 + before_match = text[line_start : m.start()] + if before_match.lstrip().startswith("#"): + continue + if line: + result.append(line) + return result + + +def extract_pytest_targets_from_line(line: str) -> list[str]: + """ + Parse pytest test targets (file path or marker expression) from a single command line. + Returns e.g. ['tests/xxx.py']. + When there is no explicit test file but only -m/--run-level, returns + ['-m expr'] or ['-m expr --run-level level'] with the full marker args. + """ + if "pytest" not in line: + return [] + # Strip leading timeout and anything before pytest + idx = line.find("pytest") + if idx == -1: + return [] + rest = line[idx:].strip() + if not rest.startswith("pytest"): + return [] + # Simple split on whitespace (no handling of quoted spaces) + parts = re.split(r"\s+", rest, maxsplit=1) + args = parts[1].strip() if len(parts) > 1 else "" + if not args: + return [] + targets = [] + # Match tests/.../*.py + for m in re.finditer(r"tests/[^\s'\"#]+\.py", args): + path = m.group(0) + if path not in targets: + targets.append(path) + if not targets: + # May be -m/--run-level only; record full marker arguments instead of a generic marker. + extra = _parse_extra_args_from_line(line) + if extra: + # Join into a single string, e.g. "-m core and cpu --run-level nightly" + targets.append(" ".join(extra)) + return targets + + +def get_pytest_targets_from_step(step: dict) -> list[tuple[str, str]]: + """ + Extract all pytest-related targets from a step. + Returns [(target, raw_line), ...] where target is tests/xxx.py or '(marker/run-level)'. + """ + lines = normalize_commands(step) + out = [] + seen = set() + for line in lines: + for t in extract_pytest_targets_from_line(line): + if t not in seen: + seen.add(t) + out.append((t, line)) + return out + + +def _parse_extra_args_from_line(raw_line: str) -> list[str]: + """Extract -m and --run-level args from a raw pytest command line. Strips extra spaces and quotes. + Correctly handles quoted expressions with spaces, e.g. -m 'core_model and cpu'. + """ + extra: list[str] = [] + + # -m: match -m followed by '...', "..." or a single token (so -m expression is one argv) + m = re.search(r"-m\s+(?:'([^']*)'|\"([^\"]*)\"|(\S+))", raw_line) + if m: + value = (m.group(1) or m.group(2) or m.group(3) or "").strip() + if value: + extra.extend(["-m", value]) + + r = re.search(r"--run-level\s+(?:'([^']*)'|\"([^\"]*)\"|(\S+))", raw_line) + if r: + value = (r.group(1) or r.group(2) or r.group(3) or "").strip() + if value: + extra.extend(["--run-level", value]) + + return extra + + +def _parse_collect_only_stdout(stdout: str, *, raise_on_empty: bool = True, stderr: str = "") -> list[str]: + """ + Parse pytest --collect-only -q stdout into a list of node ids. + Normalizes \\r\\n and \\r to \\n before splitting. + If raise_on_empty and no node ids found, raises RuntimeError; otherwise returns []. + """ + raw = (stdout or "").replace("\r\n", "\n").replace("\r", "\n") + out: list[str] = [] + for line in raw.splitlines(): + line = line.strip() + if not line or "::" not in line or "test_" not in line: + continue + out.append(line) + if not out and raise_on_empty: + raise RuntimeError( + f"Failed to parse pytest --collect-only output: no node ids found.\nstdout:\n{stdout}\n\nstderr:\n{stderr}" + ) + return out + + +def _resolve_pytest_target(target: str, repo_root: Path, extra: list[str], raw_line: str) -> tuple[str, str, int]: + """ + Resolve target + repo_root + extra to (path_arg, fallback_path, timeout). + Raises FileNotFoundError/RuntimeError on invalid config. + """ + if target.endswith(".py"): + path = (repo_root / target).resolve() + if not path.exists(): + raise FileNotFoundError(f"Pytest target path does not exist: {path}") + return str(path), target.replace("\\", "/"), 60 + if not extra: + raise RuntimeError(f"Failed to parse -m/--run-level from pytest line: {raw_line!r}") + if not (repo_root / "tests").exists(): + raise FileNotFoundError("tests/ directory not found under repo root") + return "tests/", "tests/", 120 + + +def collect_test_names(target: str, repo_root: Path, raw_line: str) -> list[str]: + """ + Run pytest --collect-only -q for a target and return test node id list (incl. parametrized). + target: path like tests/foo.py or marker string (e.g. "-m expr"). raw_line used for -m/--run-level. + """ + extra = _parse_extra_args_from_line(raw_line) + path_arg, _fallback, timeout_quiet = _resolve_pytest_target(target, repo_root, extra, raw_line) + + cmd_quiet = [sys.executable, "-m", "pytest", path_arg, "--collect-only", "-q"] + cmd_quiet.extend(extra) + result = subprocess.run( + cmd_quiet, + cwd=repo_root, + capture_output=True, + text=True, + timeout=timeout_quiet, + ) + if result.returncode != 0: + raise RuntimeError(f"pytest --collect-only failed for {path_arg} with args {extra}: {result.stderr.strip()}") + print(f"pytest --collect-only success for {path_arg} with args {extra}: {result.stdout.strip()}") + return _parse_collect_only_stdout(result.stdout or "", stderr=result.stderr or "") + + +def _extra_with_skip_marker(extra: list[str]) -> list[str]: + """Build extra args that add -m skip: if extra has -m expr then use 'expr and skip', else -m skip.""" + out: list[str] = [] + i = 0 + has_m = False + while i < len(extra): + if extra[i] == "-m" and i + 1 < len(extra): + out.extend(["-m", extra[i + 1] + " and skip"]) + has_m = True + i += 2 + elif extra[i] == "--run-level" and i + 1 < len(extra): + out.extend(["--run-level", extra[i + 1]]) + i += 2 + else: + i += 1 + if not has_m: + out = ["-m", "skip"] + out + return out + + +def get_skip_status(target: str, raw_line: str, repo_root: Path) -> set[str]: + """ + Use pytest -m skip --collect-only -q to collect tests marked with skip; return their node ids. + Does not run tests; only collects. Supports both pytest xxx.py and pytest -m "marker". + """ + extra = _parse_extra_args_from_line(raw_line) + try: + path_arg, _, timeout_quiet = _resolve_pytest_target(target, repo_root, extra, raw_line) + except (FileNotFoundError, RuntimeError): + return set() + + extra_skip = _extra_with_skip_marker(extra) + cmd = [sys.executable, "-m", "pytest", path_arg, "--collect-only", "-q"] + cmd.extend(extra_skip) + try: + result = subprocess.run( + cmd, + cwd=repo_root, + capture_output=True, + text=True, + timeout=timeout_quiet, + ) + except (subprocess.TimeoutExpired, FileNotFoundError): + return set() + if result.returncode != 0: + return set() + ids = _parse_collect_only_stdout(result.stdout or "", raise_on_empty=False) + return set(ids) + + +def get_docstring_for_node_id(repo_root: Path, node_id: str) -> str: + """ + Parse the docstring of the test_ function/method from source by pytest node id. + Supports path::test_func, path::TestClass::test_method, path::test_func[param], etc. + """ + if not node_id or "::" not in node_id or node_id.startswith("["): + return "" + parts = node_id.split("::") + # Path accepts forward slashes; no platform-specific replacement needed + path = (repo_root / parts[0]).resolve() + if not path.exists(): + return "" + # Strip parametrize suffix, e.g. test_foo[param] -> test_foo + func_part = parts[-1] + if "[" in func_part: + func_part = func_part.split("[", 1)[0] + try: + source = path.read_text(encoding="utf-8", errors="replace") + tree = ast.parse(source) + except Exception: + return "" + # parts: [file, maybe_class, func] or [file, func] + if len(parts) == 2: + # path::test_func + target_func = func_part + target_class = None + else: + # path::TestClass::test_method + target_class = parts[1] + target_func = func_part + for node in tree.body: + if isinstance(node, ast.FunctionDef) and node.name == target_func and target_class is None: + doc = ast.get_docstring(node) + return (doc or "").strip() + if isinstance(node, ast.ClassDef) and node.name == target_class: + for item in node.body: + if isinstance(item, ast.FunctionDef) and item.name == target_func: + doc = ast.get_docstring(item) + return (doc or "").strip() + break + return "" + + +def get_test_file_for_node_id(node_id: str) -> str: + """Extract test file path from a pytest node id.""" + if not node_id: + return "" + if "::" in node_id: + return node_id.split("::", 1)[0] + if node_id.endswith(".py"): + return node_id + return "(marker-only / no collection)" + + +def write_html(all_stats: list[tuple], out_path: Path, total: int, repo_root: Path | None = None) -> None: + """Write stats to HTML with styling and responsive tables.""" + root = repo_root if repo_root is not None else REPO_ROOT + pipeline_badges = { + "test-ready": "ready", + "test-merge": "merge", + "test-nightly": "nightly", + } + + def esc(s: str) -> str: + return html.escape(str(s)) + + def tr_class(i: int) -> str: + return "odd" if i % 2 == 0 else "even" + + pipeline_totals: dict[str, int] = {} + for item in all_stats: + _, pipeline_name, _, _, _, _, count, _, _, _ = item + pipeline_totals[pipeline_name] = pipeline_totals.get(pipeline_name, 0) + count + pipeline_summary_rows = [] + for idx, (pipeline_name, pipeline_count) in enumerate(sorted(pipeline_totals.items())): + badge = pipeline_badges.get(pipeline_name, "") + badge_span = f'{esc(pipeline_name)}' if badge else esc(pipeline_name) + pipeline_summary_rows.append( + f'{badge_span}{pipeline_count}' + ) + + # Summary rows by module (item: ..., names, docstrings, skipped_ids) + summary_rows = [] + for i, item in enumerate(all_stats): + _, pipeline_name, label, test_files, _, marker_targets, count, _, _, _ = item + if test_files: + files_html = "
".join(esc(f) for f in test_files) + elif marker_targets: + # Display full marker / run-level arguments when no explicit test files. + files_html = "
".join(esc(m) for m in marker_targets) + else: + files_html = "No explicit tests or markers" + badge = pipeline_badges.get(pipeline_name, "") + badge_span = f'{esc(pipeline_name)}' if badge else esc(pipeline_name) + summary_rows.append( + f'' + f'{badge_span}{esc(label)}{count}' + f'{files_html}' + ) + + # Detail rows grouped by Pipeline, then by Test Suite (two-level collapsible) + case_details_thead = """ + + Pipeline + Test Suite + Test File + No. + Test Name + Status + Description + """ + # Per-item: (pipeline_name, label, count, badge_span, suite_rows_html) + pipeline_suites: dict[str, list[tuple[str, int, str, str]]] = {} # pipeline -> [(label, count, summary, body)] + for item in all_stats: + _, pipeline_name, label, _, _, _, count, names, docstrings, skipped_ids = item + badge = pipeline_badges.get(pipeline_name, "") + badge_span = f'{esc(pipeline_name)}' if badge else esc(pipeline_name) + suite_summary = f"{esc(label)} ({count} cases)" + file_sections: list[str] = [] + if not names: + suite_rows = [ + f'' + f"{badge_span}{esc(label)}" + f'(marker-only / no collection)0' + f'No collection or only -m/--run-level' + f'' + ] + file_sections.append( + '
' + "(marker-only / no collection)" + f'
{case_details_thead}' + f"{''.join(suite_rows)}
" + ) + else: + file_groups: dict[str, list[tuple[str, str, bool]]] = {} + for idx, name in enumerate(names): + desc = (docstrings[idx] if idx < len(docstrings) else "") or "" + if not desc and "::" in name and not name.startswith("["): + desc = get_docstring_for_node_id(root, name) + file_key = get_test_file_for_node_id(name) + file_groups.setdefault(file_key, []).append((name, desc, name in skipped_ids)) + + for file_name in sorted(file_groups.keys()): + suite_rows = [] + for i, (name, desc, is_skipped) in enumerate(file_groups[file_name], 1): + desc_html = esc(desc).replace("\n", "
") if desc else "" + status_html = 'Skipped' if is_skipped else "—" + suite_rows.append( + f'' + f'{badge_span}{esc(label)}{esc(file_name)}' + f'{i}' + f'{esc(name)}' + f'{status_html}{desc_html}' + ) + file_sections.append( + f'
' + f"{esc(file_name)} ({len(file_groups[file_name])} cases)" + f'
{case_details_thead}' + f"{''.join(suite_rows)}
" + ) + body = "".join(file_sections) + if pipeline_name not in pipeline_suites: + pipeline_suites[pipeline_name] = [] + pipeline_suites[pipeline_name].append((label, count, suite_summary, body)) + + # Build two-level HTML: Pipeline (outer) -> Test Suite (inner) -> table + detail_sections_html_parts = [] + for pipeline_name in sorted(pipeline_badges.keys()): + if pipeline_name not in pipeline_suites: + continue + suites = pipeline_suites[pipeline_name] + pipeline_total = sum(c for _, c, _, _ in suites) + badge = pipeline_badges.get(pipeline_name, "") + badge_span = f'{esc(pipeline_name)}' if badge else esc(pipeline_name) + pipeline_summary = f"{badge_span} ({pipeline_total} cases)" + inner_html = "".join( + f'
{suite_summary}' + f'
{body}
' + for _, _, suite_summary, body in suites + ) + detail_sections_html_parts.append( + f'
{pipeline_summary}' + f'
{inner_html}
' + ) + # Include pipelines that might be in all_stats but not in pipeline_badges (e.g. custom names) + for pipeline_name in sorted(pipeline_suites.keys()): + if pipeline_name in pipeline_badges: + continue + suites = pipeline_suites[pipeline_name] + pipeline_total = sum(c for _, c, _, _ in suites) + pipeline_summary = f"{esc(pipeline_name)} ({pipeline_total} cases)" + inner_html = "".join( + f'
{suite_summary}' + f'
{body}
' + for _, _, suite_summary, body in suites + ) + detail_sections_html_parts.append( + f'
{pipeline_summary}' + f'
{inner_html}
' + ) + detail_sections_html = "".join(detail_sections_html_parts) + + html_content = f""" + + + + + Buildkite Test Case Statistics + + + +
+

Buildkite Pytest Case Statistics

+

Test Suites: {len(all_stats)} · Total cases (sum of steps): {total} ·

+ +
+

Pipeline Summary

+
+ + + + + + {"".join(pipeline_summary_rows)} + +
PipelineTotal Cases
+
+
+ +
+

Summary by Test Suite

+
+ + + + + + {"".join(summary_rows)} + +
PipelineTest SuiteCasesTest Files
+
+
+ +
+

Case Details

+

Expand by Pipeline, then by Test Suite, then by Test File.

+
+ + + Filter applies to the Test File fold sections below. +
+ {detail_sections_html} +
+
+
↑ Top + + + +""" + out_path.write_text(html_content, encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Count pytest cases from Buildkite pipelines") + parser.add_argument( + "--buildkite-dir", + type=Path, + default=BUILDKITE_DIR, + help="Path to .buildkite directory", + ) + default_out = REPO_ROOT / "buildkite_testcase_statistics.html" + parser.add_argument( + "-o", + "--output", + type=Path, + default=default_out, + help=f"Output HTML file path (default: {default_out})", + ) + args = parser.parse_args() + + all_stats = [] + + for filename in PIPELINE_FILES: + yml_path = args.buildkite_dir / filename + if not yml_path.exists(): + print(f"Skipped (not found): {yml_path}", file=sys.stderr) + continue + data = load_yaml(yml_path) + steps = data.get("steps") or [] + pipeline_name = filename.replace(".yml", "") + + for step in steps: + if not isinstance(step, dict): + continue + label = step.get("label") or "(no label)" + # Skip commented-out steps (usually no label or commands) + if not step.get("commands"): + continue + targets = get_pytest_targets_from_step(step) + if not targets: + continue + + module_key = f"{pipeline_name} / {label}" + # test_files: only real .py files; marker-only steps will have an empty list. + test_files = [t for t, _ in targets if t.endswith(".py")] + # marker_targets: synthetic marker targets (e.g. "-m expr", "-m expr --run-level level"). + marker_targets = [t for t, _ in targets if not t.endswith(".py")] + # marker_only: all targets are marker expressions (no explicit .py files). + marker_only = bool(targets) and all(not t.endswith(".py") for t, _ in targets) + + names = [] + skipped_ids: set[str] = set() + for target, raw in targets: + collected = collect_test_names(target, REPO_ROOT, raw) + skipped_ids.update(get_skip_status(target, raw, REPO_ROOT)) + names.extend(collected) + count = len(names) - len(skipped_ids) + docstrings = [ + get_docstring_for_node_id(REPO_ROOT, n) if "::" in n and not n.startswith("[") else "" for n in names + ] + all_stats.append( + ( + module_key, + pipeline_name, + label, + test_files, + marker_only, + marker_targets, + count, + names, + docstrings, + skipped_ids, + ) + ) + + out_path = args.output.resolve() + out_path.parent.mkdir(parents=True, exist_ok=True) + total = sum(item[6] for item in all_stats) + write_html(all_stats, out_path, total, REPO_ROOT) + print(f"Written: {out_path}") + print(f"Test Suites: {len(all_stats)}, total cases (sum of steps): {total}") + + +if __name__ == "__main__": + main() diff --git a/tools/nightly/send_nightly_email.py b/tools/nightly/send_nightly_email.py new file mode 100644 index 000000000..723a5c3d5 --- /dev/null +++ b/tools/nightly/send_nightly_email.py @@ -0,0 +1,351 @@ +#!/usr/bin/env python3 +""" +Send the nightly report by email to DAILY_EMAIL_LIST. + +Reads SMTP and recipient config from environment variables. Use --dry-run to +validate config and print subject/body without sending. +""" + +from __future__ import annotations + +import argparse +import logging +import mimetypes +import os +import smtplib +import sys +from datetime import date +from email.header import Header +from email.mime.application import MIMEApplication +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +LOGGER = logging.getLogger(__name__) + +# Env keys for SMTP and recipients. +ENV_SMTP_HOST = "SMTP_HOST" +ENV_SMTP_PORT = "SMTP_PORT" +ENV_SMTP_USERNAME = "SMTP_USERNAME" +ENV_SMTP_PASSWORD = "SMTP_PASSWORD" +ENV_DAILY_EMAIL_LIST = "DAILY_EMAIL_LIST" +ENV_EMAIL_SENDER = "EMAIL_SENDER" +ENV_EMAIL_SUBJECT_PREFIX = "EMAIL_SUBJECT_PREFIX" +ENV_BUILD_URL = "BUILDKITE_BUILD_URL" +ENV_COMMIT = "BUILDKITE_COMMIT" + +DEFAULT_OUTPUT_DIR = os.getenv("DEFAULT_OUTPUT_DIR") + +# Do not attach file if size >= this (bytes); body will suggest downloading from build URL. +MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 + +SMTP_RETRIES = 3 +SMTP_RETRY_DELAY_SEC = 5 + + +def _strip_quoted(s: str) -> str: + """Remove one layer of surrounding quote characters from env values. + + Handles both ASCII quotes ('", ''') and common Unicode quotes (“ ” ‘ ’). + """ + if not s: + return s + s = s.strip() + if not s: + return s + quotes = {'"', "'", "“", "”", "‘", "’"} + if len(s) >= 2 and s[0] in quotes and s[-1] in quotes: + s = s[1:-1].strip() + return s + + +def _get_required_env() -> dict[str, str]: + """Read required env vars; raise SystemExit with clear message if any missing.""" + required = { + ENV_SMTP_HOST: os.environ.get(ENV_SMTP_HOST), + ENV_SMTP_PORT: os.environ.get(ENV_SMTP_PORT), + ENV_SMTP_USERNAME: os.environ.get(ENV_SMTP_USERNAME), + ENV_SMTP_PASSWORD: os.environ.get(ENV_SMTP_PASSWORD), + ENV_DAILY_EMAIL_LIST: os.environ.get(ENV_DAILY_EMAIL_LIST), + } + missing = [k for k, v in required.items() if not (v and str(v).strip())] + if missing: + raise SystemExit(f"Missing required env vars: {', '.join(missing)}. Set them (e.g. in Buildkite secrets).") + return {k: _strip_quoted(str(v)) for k, v in required.items()} + + +def _get_latest_file(path: str) -> str | list[str]: + """ + Resolve report file patterns into concrete files. + + - If path is a directory: return all files in that directory (list). + - If path contains wildcards (e.g. *.xlsx, *.html): expand using glob and + return all matching files (list). + - If path is a plain filename or file path: treat as (dir, filename), + search that directory for files with the same basename and return the + latest modified one (str). + + Note: higher-level callers will turn a single str into [str] when they + want to attach multiple files. + """ + import glob + + # Support comma-/semicolon-separated list: e.g. "*.xlsx, *.html; reports/foo.xlsx" + parts = [p.strip() for p in path.split(";") for p in p.split(",") if p and p.strip()] + if len(parts) > 1: + files: list[str] = [] + for part in parts: + files_part = _get_latest_file(part) + if isinstance(files_part, str): + files.append(files_part) + else: + files.extend(files_part) + if not files: + raise SystemExit(f"No files resolved from list: {path}") + return sorted(set(files)) + + # Single expression after splitting + path = parts[0] if parts else path + + # Directory: return all files inside + if os.path.isdir(path): + files = [os.path.join(path, f) for f in os.listdir(path) if os.path.isfile(os.path.join(path, f))] + if not files: + raise SystemExit(f"No files found in {path}") + return sorted(files) + + # Wildcard pattern: expand via glob + if any(ch in path for ch in ("*", "?", "[")) and not os.path.exists(path): + matches = [p for p in glob.glob(path) if os.path.isfile(p)] + if not matches: + raise SystemExit(f"No files match pattern {path!r}") + return sorted(matches) + + # Plain filename or explicit file path: search its directory for same basename + parent = os.path.dirname(path) or "." + base = os.path.basename(path) + if not base: + raise SystemExit(f"Invalid path (no filename): {path}") + if not os.path.isdir(parent): + raise SystemExit(f"Directory not found: {parent}") + candidates = [ + p + for p in (os.path.join(parent, f) for f in os.listdir(parent)) + if os.path.basename(p) == base and os.path.isfile(p) + ] + if not candidates: + raise SystemExit(f"No file named {base!r} found in {parent}") + return max(candidates, key=os.path.getmtime) + + +def _recipients_list(comma_separated: str) -> list[str]: + """Parse DAILY_EMAIL_LIST into a list of addresses.""" + return [_strip_quoted(a.strip()) for a in comma_separated.split(",") if a and a.strip()] + + +def _build_body( + date_str: str, + commit_sha: str | None, + build_url: str | None, + attachment_skipped: bool, +) -> str: + """Plain-text body with build metadata; note when attachment was skipped.""" + lines = [ + f"Nightly report date: {date_str}", + "", + f"Commit: {commit_sha or 'N/A'}", + f"Build: {build_url or 'N/A'}", + "", + ] + if attachment_skipped: + lines.append( + "Some report file(s) were too large to attach. Please download from the build artifacts (Build URL above)." + ) + lines.append("") + return "\n".join(lines) + + +def _build_subject(prefix: str | None, date_str: str) -> str: + """Subject line: optional prefix + date.""" + base = f"Nightly Report {date_str}" + if prefix and prefix.strip(): + return f"{prefix.strip()} {base}" + return base + + +def _mime_type_and_subtype(file_path: str) -> tuple[str, str]: + """Return (maintype, subtype) for the file; fallback to application/octet-stream.""" + full_type, _ = mimetypes.guess_type(file_path, strict=False) + if not full_type: + return "application", "octet-stream" + parts = full_type.split("/", 1) + if len(parts) != 2: + return "application", "octet-stream" + return parts[0], parts[1] + + +def _attach_file(msg: MIMEMultipart, file_path: str, max_bytes: int) -> bool: + """ + Attach one file to msg with correct MIME type. Return True if attached, False if skipped (e.g. size). + """ + if not os.path.isfile(file_path): + return False + size = os.path.getsize(file_path) + if size <= 0 or size >= max_bytes: + return False + maintype, subtype = _mime_type_and_subtype(file_path) + name = os.path.basename(file_path) + try: + if maintype == "text": + with open(file_path, encoding="utf-8", errors="replace") as f: + part = MIMEText(f.read(), subtype, "utf-8") + else: + with open(file_path, "rb") as f: + part = MIMEApplication(f.read(), _subtype=subtype) + part.add_header("Content-Disposition", "attachment", filename=name) + msg.attach(part) + return True + except OSError: + return False + + +def _send_mail( + report_files: list[str], + date_str: str, + dry_run: bool, +) -> None: + """Load config, build message (attach each file with correct MIME type), and send (or dry-run).""" + cfg = _get_required_env() + recipients = _recipients_list(cfg[ENV_DAILY_EMAIL_LIST]) + if not recipients: + raise SystemExit("DAILY_EMAIL_LIST is empty after parsing.") + + commit_sha = os.environ.get(ENV_COMMIT) + build_url = os.environ.get(ENV_BUILD_URL) + # Allow EMAIL_SENDER / EMAIL_SUBJECT_PREFIX to be quoted; strip quotes if present. + raw_sender = os.environ.get(ENV_EMAIL_SENDER) + sender = _strip_quoted(raw_sender) if raw_sender else cfg[ENV_SMTP_USERNAME] + raw_prefix = os.environ.get(ENV_EMAIL_SUBJECT_PREFIX) + prefix = _strip_quoted(raw_prefix) if raw_prefix else None + + attachment_skipped = any(os.path.isfile(p) and os.path.getsize(p) >= MAX_ATTACHMENT_BYTES for p in report_files) + body = _build_body( + commit_sha=commit_sha, build_url=build_url, date_str=date_str, attachment_skipped=attachment_skipped + ) + subject = _build_subject(prefix=prefix, date_str=date_str) + + msg = MIMEMultipart() + msg["Subject"] = Header(subject, "utf-8") + msg["From"] = sender + msg["To"] = ", ".join(recipients) + msg.attach(MIMEText(body, "plain", "utf-8")) + + attached_count = 0 + for file_path in report_files: + if os.path.isfile(file_path) and os.path.getsize(file_path) >= MAX_ATTACHMENT_BYTES: + attachment_skipped = True + else: + if _attach_file(msg, file_path, MAX_ATTACHMENT_BYTES): + attached_count += 1 + elif os.path.isfile(file_path): + attachment_skipped = True + + if dry_run: + LOGGER.info("dry-run: not sending mail") + print("To:", recipients, file=sys.stderr) + print("Subject:", subject, file=sys.stderr) + print("Attachments:", attached_count, "(skipped large:", attachment_skipped, ")", file=sys.stderr) + print("Body preview:", body[:300] + ("..." if len(body) > 300 else ""), file=sys.stderr) + return + + host = cfg[ENV_SMTP_HOST] + port = int(cfg[ENV_SMTP_PORT], 10) + use_ssl = port == 465 + last_err: Exception | None = None + for attempt in range(SMTP_RETRIES): + try: + if use_ssl: + with smtplib.SMTP_SSL(host, port=port, timeout=30) as smtp: + smtp.login(cfg[ENV_SMTP_USERNAME], cfg[ENV_SMTP_PASSWORD]) + smtp.sendmail(sender, recipients, msg.as_string()) + else: + with smtplib.SMTP(host, port=port, timeout=30) as smtp: + smtp.starttls() + smtp.login(cfg[ENV_SMTP_USERNAME], cfg[ENV_SMTP_PASSWORD]) + smtp.sendmail(sender, recipients, msg.as_string()) + LOGGER.info("sent nightly email to %d recipient(s)", len(recipients)) + return + except Exception as e: + last_err = e + LOGGER.warning("SMTP attempt %d/%d failed: %s", attempt + 1, SMTP_RETRIES, e) + if attempt < SMTP_RETRIES - 1: + import time + + time.sleep(SMTP_RETRY_DELAY_SEC) + hint = "" + if last_err and "getaddrinfo" in str(last_err).lower(): + hint = f" (check SMTP_HOST={host!r} is correct and reachable, e.g. nslookup {host})" + raise SystemExit(f"Failed to send email after {SMTP_RETRIES} attempts.{hint}") from last_err + + +def _vllm_omni_root() -> str: + """Resolve vllm-omni repo root: directory that contains a 'tests' subdir (and usually 'tools').""" + path = os.path.dirname(os.path.abspath(__file__)) + while path and path != os.path.dirname(path): + if os.path.isdir(os.path.join(path, "tests")): + return path + path = os.path.dirname(path) + return os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")) + + +def _default_output_dir() -> str: + """Default: vllm-omni root / DEFAULT_OUTPUT_DIR (where nightly report files live).""" + root = _vllm_omni_root() + subdir = DEFAULT_OUTPUT_DIR if (DEFAULT_OUTPUT_DIR and DEFAULT_OUTPUT_DIR.strip()) else "." + return os.path.join(root, subdir) + + +def parse_args() -> argparse.Namespace: + """Parse command-line arguments.""" + parser = argparse.ArgumentParser( + description="Send nightly report by email (config from env).", + ) + parser.add_argument( + "--report-file", + type=str, + default=_default_output_dir(), + help="Folder/file path to the sent email file; default is DEFAULT_OUTPUT_DIR.", + ) + parser.add_argument( + "--date", + type=str, + default=None, + help="Date string for subject/body (default: inferred from report filename).", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print recipient, subject, and body; do not send.", + ) + return parser.parse_args() + + +def main() -> None: + """Entrypoint.""" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + ) + args = parse_args() + + result = _get_latest_file(args.report_file) + report_files = result if isinstance(result, list) else [result] + report_files = [p for p in report_files if os.path.isfile(p)] + if not report_files: + raise SystemExit("No report file(s) found.") + + date_str = args.date or date.today().strftime("%Y-%m-%d") + _send_mail(report_files=report_files, date_str=date_str, dry_run=args.dry_run) + + +if __name__ == "__main__": + main() diff --git a/tools/nightly/send_nightly_perf_email.py b/tools/nightly/send_nightly_perf_email.py deleted file mode 100644 index 9b4bf7ece..000000000 --- a/tools/nightly/send_nightly_perf_email.py +++ /dev/null @@ -1,234 +0,0 @@ -#!/usr/bin/env python3 -""" -Send the nightly performance Excel report by email to DAILY_EMAIL_LIST. - -Reads SMTP and recipient config from environment variables. Use --dry-run to -validate config and print subject/body without sending. -""" - -from __future__ import annotations - -import argparse -import logging -import os -import smtplib -import sys -from email.header import Header -from email.mime.application import MIMEApplication -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText - -LOGGER = logging.getLogger(__name__) - -# Env keys for SMTP and recipients. -ENV_SMTP_HOST = "SMTP_HOST" -ENV_SMTP_PORT = "SMTP_PORT" -ENV_SMTP_USERNAME = "SMTP_USERNAME" -ENV_SMTP_PASSWORD = "SMTP_PASSWORD" -ENV_DAILY_EMAIL_LIST = "DAILY_EMAIL_LIST" -ENV_EMAIL_SENDER = "EMAIL_SENDER" -ENV_EMAIL_SUBJECT_PREFIX = "EMAIL_SUBJECT_PREFIX" -ENV_BUILD_URL = "BUILDKITE_BUILD_URL" -ENV_COMMIT = "BUILDKITE_COMMIT" - -DEFAULT_OUTPUT_DIR = os.getenv("DEFAULT_OUTPUT_DIR") - -# Do not attach Excel if size >= this (bytes); body will suggest downloading from build URL. -MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 - -SMTP_RETRIES = 3 -SMTP_RETRY_DELAY_SEC = 5 - - -def _get_required_env() -> dict[str, str]: - """Read required env vars; raise SystemExit with clear message if any missing.""" - required = { - ENV_SMTP_HOST: os.environ.get(ENV_SMTP_HOST), - ENV_SMTP_PORT: os.environ.get(ENV_SMTP_PORT), - ENV_SMTP_USERNAME: os.environ.get(ENV_SMTP_USERNAME), - ENV_SMTP_PASSWORD: os.environ.get(ENV_SMTP_PASSWORD), - ENV_DAILY_EMAIL_LIST: os.environ.get(ENV_DAILY_EMAIL_LIST), - } - missing = [k for k, v in required.items() if not (v and str(v).strip())] - if missing: - raise SystemExit(f"Missing required env vars: {', '.join(missing)}. Set them (e.g. in Buildkite secrets).") - return {k: str(v).strip() for k, v in required.items()} - - -def _get_latest_file(folder_path: str) -> str: - """Get the latest modified file from the folder path.""" - files = [os.path.join(folder_path, f) for f in os.listdir(folder_path) if f.endswith(".xlsx")] - if not files: - raise SystemExit(f"No Excel files found in {folder_path}") - return max(files, key=os.path.getmtime) - - -def _recipients_list(comma_separated: str) -> list[str]: - """Parse DAILY_EMAIL_LIST into a list of addresses.""" - return [a.strip() for a in comma_separated.split(",") if a.strip()] - - -def _build_body( - date_str: str, - commit_sha: str | None, - build_url: str | None, - attachment_skipped: bool, -) -> str: - """Plain-text body with build metadata; note when attachment was skipped.""" - lines = [ - f"Nightly performance report date: {date_str}", - "", - f"Commit: {commit_sha or 'N/A'}", - f"Build: {build_url or 'N/A'}", - "", - ] - if attachment_skipped: - lines.append( - "Report file was too large to attach. Please download the Excel from the build artifacts (Build URL above)." - ) - lines.append("") - return "\n".join(lines) - - -def _build_subject(prefix: str | None, date_str: str) -> str: - """Subject line: optional prefix + date.""" - base = f"Nightly Perf {date_str}" - if prefix and prefix.strip(): - return f"{prefix.strip()} {base}" - return base - - -def _send_mail( - report_file: str, - date_str: str, - dry_run: bool, -) -> None: - """Load config, build message, and send (or dry-run).""" - cfg = _get_required_env() - recipients = _recipients_list(cfg[ENV_DAILY_EMAIL_LIST]) - if not recipients: - raise SystemExit("DAILY_EMAIL_LIST is empty after parsing.") - - commit_sha = os.environ.get(ENV_COMMIT) - build_url = os.environ.get(ENV_BUILD_URL) - sender = os.environ.get(ENV_EMAIL_SENDER) or cfg[ENV_SMTP_USERNAME] - prefix = os.environ.get(ENV_EMAIL_SUBJECT_PREFIX) - - size = os.path.getsize(report_file) if os.path.isfile(report_file) else 0 - attach_excel = size < MAX_ATTACHMENT_BYTES and size > 0 - attachment_skipped = os.path.isfile(report_file) and not attach_excel - - body = _build_body( - commit_sha=commit_sha, build_url=build_url, date_str=date_str, attachment_skipped=attachment_skipped - ) - subject = _build_subject(prefix=prefix, date_str=date_str) - - msg = MIMEMultipart() - msg["Subject"] = Header(subject, "utf-8") - msg["From"] = sender - msg["To"] = ", ".join(recipients) - msg.attach(MIMEText(body, "plain", "utf-8")) - - if attach_excel: - with open(report_file, "rb") as f: - part = MIMEApplication(f.read(), _subtype="vnd.openxmlformats-officedocument.spreadsheetml.sheet") - part.add_header("Content-Disposition", "attachment", filename=os.path.basename(report_file)) - msg.attach(part) - - if dry_run: - LOGGER.info("dry-run: not sending mail") - print("To:", recipients, file=sys.stderr) - print("Subject:", subject, file=sys.stderr) - print("Attachment:", "yes" if attach_excel else "no (size limit)", file=sys.stderr) - print("Body preview:", body[:300] + ("..." if len(body) > 300 else ""), file=sys.stderr) - return - - port = int(cfg[ENV_SMTP_PORT], 10) - last_err: Exception | None = None - for attempt in range(SMTP_RETRIES): - try: - with smtplib.SMTP(cfg[ENV_SMTP_HOST], port=port, timeout=30) as smtp: - smtp.starttls() - smtp.login(cfg[ENV_SMTP_USERNAME], cfg[ENV_SMTP_PASSWORD]) - smtp.sendmail(sender, recipients, msg.as_string()) - LOGGER.info("sent nightly perf email to %d recipient(s)", len(recipients)) - return - except Exception as e: - last_err = e - LOGGER.warning("SMTP attempt %d/%d failed: %s", attempt + 1, SMTP_RETRIES, e) - if attempt < SMTP_RETRIES - 1: - import time - - time.sleep(SMTP_RETRY_DELAY_SEC) - raise SystemExit(f"Failed to send email after {SMTP_RETRIES} attempts.") from last_err - - -def _date_from_filename(path: str) -> str: - """Try to derive a date string from report filename (e.g. nightly_perf_20260211-020704.xlsx -> 2026-02-11).""" - base = os.path.splitext(os.path.basename(path))[0] - if base.startswith("nightly_perf_") and len(base) > 13: - raw = base.replace("nightly_perf_", "")[:8] - if len(raw) == 8 and raw.isdigit(): - return f"{raw[:4]}-{raw[4:6]}-{raw[6:8]}" - return base or "unknown" - - -def _vllm_omni_root() -> str: - """Resolve vllm-omni repo root: directory that contains a 'tests' subdir (and usually 'tools').""" - path = os.path.dirname(os.path.abspath(__file__)) - while path and path != os.path.dirname(path): - if os.path.isdir(os.path.join(path, "tests")): - return path - path = os.path.dirname(path) - return os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")) - - -def _default_output_dir() -> str: - """Default: vllm-omni root / DEFAULT_OUTPUT_DIR (where performance .xlsx files live).""" - root = _vllm_omni_root() - return os.path.join(root, DEFAULT_OUTPUT_DIR) - - -def parse_args() -> argparse.Namespace: - """Parse command-line arguments.""" - parser = argparse.ArgumentParser( - description="Send nightly performance Excel report by email (config from env).", - ) - parser.add_argument( - "--report-file", - type=str, - default=_default_output_dir(), - help="Folder/file path to the nightly_perf_*.xlsx file; default is DEFAULT_OUTPUT_DIR.", - ) - parser.add_argument( - "--date", - type=str, - default=None, - help="Date string for subject/body (default: inferred from report filename).", - ) - parser.add_argument( - "--dry-run", - action="store_true", - help="Print recipient, subject, and body; do not send.", - ) - return parser.parse_args() - - -def main() -> None: - """Entrypoint.""" - logging.basicConfig( - level=logging.INFO, - format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", - ) - args = parse_args() - - report_file = _get_latest_file(args.report_file) if os.path.isdir(args.report_file) else args.report_file - if not os.path.isfile(report_file): - raise SystemExit(f"Report file not found: {report_file}") - - date_str = args.date or _date_from_filename(report_file) - _send_mail(report_file=report_file, date_str=date_str, dry_run=args.dry_run) - - -if __name__ == "__main__": - main() From 0e005817f766be66edc68846ff82c9cdfff8015c Mon Sep 17 00:00:00 2001 From: Daniel Huang Date: Wed, 11 Mar 2026 21:08:34 -0700 Subject: [PATCH 19/38] [Enhancement] Patch OmniStage.try_collect() with ray alive checks (#1561) Signed-off-by: Daniel Huang Signed-off-by: Daniel Huang Co-authored-by: SYLAR <125541396+lishunyang12@users.noreply.github.com> --- vllm_omni/distributed/ray_utils/utils.py | 24 ++++++++++++++++++++++-- vllm_omni/entrypoints/omni_stage.py | 19 +++++++++++++++---- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/vllm_omni/distributed/ray_utils/utils.py b/vllm_omni/distributed/ray_utils/utils.py index 07513b660..519460256 100644 --- a/vllm_omni/distributed/ray_utils/utils.py +++ b/vllm_omni/distributed/ray_utils/utils.py @@ -176,6 +176,26 @@ def start_ray_actor( runtime_env={"env_vars": {"PYTHONPATH": os.environ.get("PYTHONPATH", "")}, "CUDA_LAUNCH_BLOCKING": "1"}, ).remote() - worker_actor.run.remote(worker_entry_fn, *args, **kwargs) + task_ref = worker_actor.run.remote(worker_entry_fn, *args, **kwargs) - return worker_actor + return worker_actor, task_ref + + +def is_ray_task_alive(task_ref: Any, **kwargs): + """Checks ray task status. Returns FALSE if ray task has exited for any reason.""" + if not RAY_AVAILABLE: + raise ImportError("ray is required to query ray tasks") + + ready, _ = ray.wait([task_ref], **kwargs) + return not bool(ready) + + +def get_ray_task_error(task_ref: Any, **kwargs) -> Exception | None: + """Gets ray task. Returns RayTaskError if ray instance exited with any error, else None.""" + if not RAY_AVAILABLE: + raise ImportError("ray is required to query ray tasks") + + try: + ray.get(task_ref, **kwargs) + except Exception as e: + return e diff --git a/vllm_omni/entrypoints/omni_stage.py b/vllm_omni/entrypoints/omni_stage.py index 19091034a..3f5a14e97 100644 --- a/vllm_omni/entrypoints/omni_stage.py +++ b/vllm_omni/entrypoints/omni_stage.py @@ -36,7 +36,12 @@ from vllm_omni.diffusion.data import OmniDiffusionConfig from vllm_omni.distributed.omni_connectors import build_stage_connectors from vllm_omni.distributed.omni_connectors.adapter import try_recv_via_connector from vllm_omni.distributed.omni_connectors.connectors.base import OmniConnectorBase -from vllm_omni.distributed.ray_utils.utils import kill_ray_actor, start_ray_actor +from vllm_omni.distributed.ray_utils.utils import ( + get_ray_task_error, + is_ray_task_alive, + kill_ray_actor, + start_ray_actor, +) from vllm_omni.engine.arg_utils import AsyncOmniEngineArgs, OmniEngineArgs from vllm_omni.entrypoints.async_omni_diffusion import AsyncOmniDiffusion from vllm_omni.entrypoints.async_omni_llm import AsyncOmniLLM @@ -303,6 +308,8 @@ class OmniStage: self._in_q: mp.queues.Queue | ZmqQueue | str | None = None self._out_q: mp.queues.Queue | ZmqQueue | str | None = None self._proc: mp.Process | None = None + self._ray_actor: Any | None = None + self._ray_task_ref: Any | None = None self._shm_threshold_bytes: int = 65536 self._stage_init_timeout: int = stage_init_timeout # Callback used by the orchestrator's output handler to stash @@ -544,7 +551,7 @@ class OmniStage: os.environ["VLLM_LOGGING_PREFIX"] = new_env if worker_backend == "ray": if is_async: - self._ray_actor = start_ray_actor( + self._ray_actor, self._ray_task_ref = start_ray_actor( _stage_worker_async_entry, ray_placement_group, self.stage_id, @@ -556,7 +563,7 @@ class OmniStage: stage_init_timeout=self._stage_init_timeout, ) else: - self._ray_actor = start_ray_actor( + self._ray_actor, self._ray_task_ref = start_ray_actor( _stage_worker, ray_placement_group, self.stage_id, @@ -629,9 +636,10 @@ class OmniStage: pass self._out_q = None - if hasattr(self, "_ray_actor") and self._ray_actor: + if self._ray_actor is not None: kill_ray_actor(self._ray_actor) self._ray_actor = None + self._ray_task_ref = None elif self._proc is not None: try: self._proc.join(timeout=5) @@ -701,6 +709,9 @@ class OmniStage: raise if self._proc is not None and not self._proc.is_alive(): raise RuntimeError(f"OmniStage Worker process died unexpectedly with exit code {self._proc.exitcode}") + if self._ray_task_ref is not None and not is_ray_task_alive(self._ray_task_ref, timeout=0): + e = get_ray_task_error(self._ray_task_ref, timeout=0) + raise RuntimeError("OmniStage Ray actor died unexpectedly") from e def process_engine_inputs( self, From 3306fa6611f54690ceb7dc44536d40cd0fbf7297 Mon Sep 17 00:00:00 2001 From: Vensen Date: Thu, 12 Mar 2026 11:17:18 +0700 Subject: [PATCH 20/38] [Feat][Diffusion]: Implement Component-Level VRAM Quota and Resource Domain Isolation (#1582) Signed-off-by: vensen --- .../test_diffusion_cpu_offload.py | 9 ++- .../diffusion/worker/diffusion_worker.py | 66 +++++++++++++++++++ vllm_omni/entrypoints/omni.py | 60 +++++++++++++++++ 3 files changed, 133 insertions(+), 2 deletions(-) diff --git a/tests/e2e/offline_inference/test_diffusion_cpu_offload.py b/tests/e2e/offline_inference/test_diffusion_cpu_offload.py index b4639a35f..f3830f02e 100644 --- a/tests/e2e/offline_inference/test_diffusion_cpu_offload.py +++ b/tests/e2e/offline_inference/test_diffusion_cpu_offload.py @@ -70,6 +70,11 @@ def test_cpu_offload_diffusion_model(model_name: str): pytest.fail("Inference failed") print(f"Offload peak memory: {offload_peak_memory} MB") print(f"No offload peak memory: {no_offload_peak_memory} MB") - assert offload_peak_memory + 2500 < no_offload_peak_memory, ( - f"Offload peak memory {offload_peak_memory} MB should be less than no offload peak memory {no_offload_peak_memory} MB" + # Set platform-specific VRAM saving thresholds to account + # for varying runtime memory overhead and fragmentation between CUDA and ROCm. + is_rocm = torch.version.hip is not None + threshold = 2500 if not is_rocm else 2100 + assert offload_peak_memory + threshold < no_offload_peak_memory, ( + f"Offload peak memory {offload_peak_memory} MB should be less than " + f"no offload peak memory {no_offload_peak_memory} MB by {threshold} MB" ) diff --git a/vllm_omni/diffusion/worker/diffusion_worker.py b/vllm_omni/diffusion/worker/diffusion_worker.py index 1b9dc5667..79a1a9ef9 100644 --- a/vllm_omni/diffusion/worker/diffusion_worker.py +++ b/vllm_omni/diffusion/worker/diffusion_worker.py @@ -9,10 +9,12 @@ to DiffusionModelRunner. """ import gc +import json import multiprocessing as mp import os from collections.abc import Iterable from contextlib import AbstractContextManager, nullcontext +from pathlib import Path from typing import Any import torch @@ -45,6 +47,16 @@ from vllm_omni.worker.gpu_memory_utils import get_process_gpu_memory logger = init_logger(__name__) +MODEL_PARAM_COUNTS = { + "bagel": 13.5e9, # BAGEL-7B-MoT + "flux": 12.0e9, # FLUX.1-dev + "default": 10.0e9, +} + +# 2.5 GiB is the empirical peak activation footprint (intermediate tensors + +# attention workspace) for models like Bagel-7B/Flux at 1024x1024 resolution. +ACTIVATION_MEMORY_MULTIPLIER = 2.5 + class DiffusionWorker: """ @@ -59,6 +71,60 @@ class DiffusionWorker: delegated to DiffusionModelRunner. """ + @staticmethod + def predict_resource_usage(od_config: OmniDiffusionConfig) -> dict[str, float]: + from vllm.utils.mem_utils import GiB_bytes + + total_params = 0 + estimate_source = "fallback" + try: + model_path = Path(getattr(od_config, "model", "")) + index_file = model_path / "model.safetensors.index.json" + if index_file.exists(): + with open(index_file) as f: + data = json.load(f) + # metadata.total_size + total_params = int(data.get("metadata", {}).get("total_size", 0)) + if total_params > 0: + estimate_source = "safetensors index" + except Exception as e: + logger.debug(f"Failed to parse safetensors metadata: {e}") + if total_params == 0: + m_name = str(getattr(od_config, "model", "")).lower() + if "bagel" in m_name: + total_params = MODEL_PARAM_COUNTS["bagel"] + estimate_source = "bagel" + elif "flux" in m_name: + total_params = MODEL_PARAM_COUNTS["flux"] + estimate_source = "flux" + else: + total_params = MODEL_PARAM_COUNTS["default"] + estimate_source = "default" + logger.info(f"VRAM Quota: Estimated {total_params / 1e9:.2f}B params using {estimate_source} logic") + + dtype = getattr(od_config, "dtype", torch.bfloat16) + dtype_str = str(dtype).lower() + # Calculate the number of bytes per parameter + # based on different levels of precision. + if "int4" in dtype_str: + bytes_per_param = 0.5 + elif any(kw in dtype_str for kw in ["float8", "int8"]): + bytes_per_param = 1 + elif any(kw in dtype_str for kw in ["float16", "bfloat16", "half"]): + bytes_per_param = 2 + elif any(kw in dtype_str for kw in ["float32", "int32"]): + bytes_per_param = 4 + else: + bytes_per_param = 4 + static_gb = (total_params * bytes_per_param) / GiB_bytes + h, w = getattr(od_config, "height", 1024), getattr(od_config, "width", 1024) + dynamic_gb = ACTIVATION_MEMORY_MULTIPLIER * (h * w / (1024 * 1024)) + return { + "static_gb": round(static_gb, 2), + "dynamic_gb": round(dynamic_gb, 2), + "total_gb": round(static_gb + dynamic_gb, 2), + } + def __init__( self, local_rank: int, diff --git a/vllm_omni/entrypoints/omni.py b/vllm_omni/entrypoints/omni.py index c414b972b..f96b4e9ea 100644 --- a/vllm_omni/entrypoints/omni.py +++ b/vllm_omni/entrypoints/omni.py @@ -313,6 +313,63 @@ class OmniBase: return config_path, stage_configs + def _coordinate_vram_resources(self) -> None: + """ + Coordinate VRAM resource reservations across stages based on their type and configs. + Note: Current implementation supports per-device isolation. Advanced multi-GPU + scenarios like TP or PP are planned as sleep mode ack Test Scenario. + """ + import torch + from vllm.utils.mem_utils import GiB_bytes + + from vllm_omni.diffusion.worker.diffusion_worker import DiffusionWorker + + # Device ID + reserved_gb_per_device: dict[int, float] = {} + active_configs = self.stage_configs + if hasattr(self, "_single_stage_id") and self._single_stage_id is not None: + active_configs = [cfg for cfg in self.stage_configs if cfg.stage_id == self._single_stage_id] + for cfg in active_configs: + s_type = getattr(cfg, "stage_type", None) + if s_type == "diffusion": + prediction = DiffusionWorker.predict_resource_usage(cfg.engine_args) + device_str = getattr(cfg.runtime, "devices", "0") + try: + devices = [int(d.strip()) for d in device_str.split(",")] + except (ValueError, AttributeError): + devices = [0] + for d_id in devices: + reserved_gb_per_device[d_id] = reserved_gb_per_device.get(d_id, 0.0) + prediction["total_gb"] + logger.info( + f"[Coordinator] Stage-{cfg.stage_id} ({s_type.capitalize()}) " + f"on devices {devices} predicted budget: {prediction['total_gb']:.2f} GiB" + ) + if not torch.cuda.is_available(): + # TODO: Add support for other accelerators (NPU/XPU) + # as their specific memory management APIs are integrated. + return + for cfg in active_configs: + if getattr(cfg, "stage_type", None) == "llm": + # Get the master device ID where the LLM is located + llm_device_str = getattr(cfg.runtime, "devices", "0") + try: + target_device = int(llm_device_str.split(",")[0].strip()) + except (ValueError, AttributeError): + target_device = 0 + # The physical total of the card containing LLM + physical_vram_gb = torch.cuda.get_device_properties(target_device).total_memory / GiB_bytes + total_reserved_on_this_device = reserved_gb_per_device.get(target_device, 0.0) + if total_reserved_on_this_device > 0: + original_util = cfg.engine_args.get("gpu_memory_utilization", 0.9) + reserved_util_ratio = total_reserved_on_this_device / physical_vram_gb + adjusted_util = min(0.95, original_util + reserved_util_ratio) + cfg.engine_args["gpu_memory_utilization"] = round(adjusted_util, 3) + logger.info( + f"[Coordinator] LLM Stage-{cfg.stage_id} on Device {target_device} dynamic boost: " + f"{original_util} -> {cfg.engine_args['gpu_memory_utilization']} " + f"(Compensating {reserved_util_ratio:.2f} ratio for resource domain isolation)" + ) + def _initialize_stages(self, model: str, kwargs: dict[str, Any]) -> None: """Initialize stage list management.""" self._inline_diffusion = False @@ -335,6 +392,9 @@ class OmniBase: # Resolve stage configs shared by orchestrator/headless paths. self.config_path, self.stage_configs = self._resolve_stage_configs(model, kwargs) + # This ensures all engines receive the corrected gpu_memory_utilization through engine_args. + self._coordinate_vram_resources() + # Initialize connectors self.omni_transfer_config, self.connectors = initialize_orchestrator_connectors( self.config_path, worker_backend=worker_backend, shm_threshold_bytes=shm_threshold_bytes From 11d9ca8f41c98dcfcc3f072171e014141ae9c661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=AA=E5=BF=97=E9=B9=8F?= Date: Thu, 12 Mar 2026 12:24:10 +0800 Subject: [PATCH 21/38] [Feature]: Enable directly use OmniLLM init AR model (#1821) --- docs/configuration/stage_configs.md | 2 +- vllm_omni/config/model.py | 6 ++++-- vllm_omni/engine/arg_utils.py | 4 ++-- vllm_omni/entrypoints/omni_llm.py | 15 +++------------ 4 files changed, 10 insertions(+), 17 deletions(-) diff --git a/docs/configuration/stage_configs.md b/docs/configuration/stage_configs.md index 412f3d524..ed69cdfe1 100644 --- a/docs/configuration/stage_configs.md +++ b/docs/configuration/stage_configs.md @@ -18,7 +18,7 @@ If users want to modify some part of it. The custom stage_configs file can be in For offline (Assume necessary dependencies have ben imported): ```python model_name = "Qwen/Qwen2.5-Omni-7B" -omni_llm = OmniLLM(model=model_name, stage_configs_path="/path/to/custom_stage_configs.yaml") +omni_llm = Omni(model=model_name, stage_configs_path="/path/to/custom_stage_configs.yaml") ``` For online serving: diff --git a/vllm_omni/config/model.py b/vllm_omni/config/model.py index fa5483b31..f837b8d45 100644 --- a/vllm_omni/config/model.py +++ b/vllm_omni/config/model.py @@ -48,7 +48,7 @@ class OmniModelConfig(ModelConfig): stage_id: int = 0 async_chunk: bool = False model_stage: str = "thinker" - model_arch: str = "Qwen2_5OmniForConditionalGeneration" + model_arch: str | None = None worker_type: str | None = None engine_output_type: str | None = None hf_config_name: str | None = None @@ -69,7 +69,9 @@ class OmniModelConfig(ModelConfig): @property def architectures(self) -> list[str]: - return [self.model_arch] + if self.model_arch is not None: + return [self.model_arch] + return super().architectures @property def embedding_size(self): diff --git a/vllm_omni/engine/arg_utils.py b/vllm_omni/engine/arg_utils.py index c4f719703..d733e7bc4 100644 --- a/vllm_omni/engine/arg_utils.py +++ b/vllm_omni/engine/arg_utils.py @@ -71,7 +71,7 @@ class OmniEngineArgs(EngineArgs): stage_id: int = 0 model_stage: str = "thinker" - model_arch: str = "Qwen2_5OmniForConditionalGeneration" + model_arch: str | None = None engine_output_type: str | None = None hf_config_name: str | None = None custom_process_next_stage_input_func: str | None = None @@ -233,7 +233,7 @@ class AsyncOmniEngineArgs(AsyncEngineArgs): stage_id: int = 0 model_stage: str = "thinker" - model_arch: str = "Qwen2_5OmniForConditionalGeneration" + model_arch: str | None = None engine_output_type: str | None = None hf_config_name: str | None = None custom_process_next_stage_input_func: str | None = None diff --git a/vllm_omni/entrypoints/omni_llm.py b/vllm_omni/entrypoints/omni_llm.py index 27f470b4b..e06e50c83 100644 --- a/vllm_omni/entrypoints/omni_llm.py +++ b/vllm_omni/entrypoints/omni_llm.py @@ -25,8 +25,6 @@ from vllm_omni.engine.input_processor import OmniInputProcessor, reinject_omni_f from vllm_omni.engine.output_processor import MultimodalOutputProcessor from vllm_omni.entrypoints.utils import ( filter_dataclass_kwargs, - load_stage_configs_from_model, - load_stage_configs_from_yaml, resolve_model_config_path, ) @@ -43,8 +41,6 @@ class OmniLLM(LLM): Args: model: Model name or path to load - stage_configs_path: Optional path to YAML file containing stage - configurations. If None, configurations are loaded from the model. log_stats: Whether to enable statistics logging compilation_config: Optional compilation configuration. Can be an integer (compilation level), dict, or CompilationConfig instance. @@ -68,7 +64,6 @@ class OmniLLM(LLM): def __init__( self, model: str, - stage_configs_path: str | None = None, log_stats: bool = False, compilation_config: int | dict[str, Any] | CompilationConfig | None = None, hf_overrides: dict[str, Any] | None = None, @@ -86,13 +81,8 @@ class OmniLLM(LLM): self.batch_timeout = batch_timeout self.log_stats: bool = bool(log_stats) - # Load stage configurations - if stage_configs_path is None: - self.config_path = resolve_model_config_path(model) - self.stage_configs = load_stage_configs_from_model(model) - else: - self.config_path = stage_configs_path - self.stage_configs = load_stage_configs_from_yaml(stage_configs_path) + # Resolve model config path for connectors + self.config_path = resolve_model_config_path(model) # Initialize connectors self.omni_transfer_config, self.connectors = initialize_orchestrator_connectors( @@ -154,6 +144,7 @@ class OmniLLM(LLM): compilation_config=compilation_config_instance, structured_outputs_config=structured_outputs_instance, omni_kv_config=omni_kv_config, + hf_overrides=hf_overrides or {}, **filter_dataclass_kwargs(OmniEngineArgs, kwargs), ) From 4dbaa74f2b83d531c8829e807bea43514587ca8f Mon Sep 17 00:00:00 2001 From: Samit <285365963@qq.com> Date: Thu, 12 Mar 2026 15:39:06 +0800 Subject: [PATCH 22/38] [Enhancement] Upgrade cache-dit from 1.2.0 to 1.3.0 (#1834) Signed-off-by: samithuang <285365963@qq.com> --- requirements/common.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/common.txt b/requirements/common.txt index 548f8f0bf..fc39c6c14 100644 --- a/requirements/common.txt +++ b/requirements/common.txt @@ -6,7 +6,7 @@ diffusers>=0.36.0 accelerate==1.12.0 gradio==5.50 soundfile>=0.13.1 -cache-dit==1.2.0 +cache-dit==1.3.0 tqdm>=4.66.0 torchsde>=0.2.6 openai-whisper>=20250625 From 1c7f963a2dcf2a432b6f2083bde5126bd226f459 Mon Sep 17 00:00:00 2001 From: wangyu <53896905+yenuo26@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:29:08 +0800 Subject: [PATCH 23/38] [Bugfix] Modify _resolve_pytest_target to support glob patterns and return multiple paths (#1843) Signed-off-by: yenuo26 <410167048@qq.com> --- .../nightly/buildkite_testcase_statistics.py | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/tools/nightly/buildkite_testcase_statistics.py b/tools/nightly/buildkite_testcase_statistics.py index 312bd6b6b..5f3217981 100644 --- a/tools/nightly/buildkite_testcase_statistics.py +++ b/tools/nightly/buildkite_testcase_statistics.py @@ -147,21 +147,31 @@ def _parse_collect_only_stdout(stdout: str, *, raise_on_empty: bool = True, stde return out -def _resolve_pytest_target(target: str, repo_root: Path, extra: list[str], raw_line: str) -> tuple[str, str, int]: +def _resolve_pytest_target(target: str, repo_root: Path, extra: list[str], raw_line: str) -> tuple[list[str], str, int]: """ - Resolve target + repo_root + extra to (path_arg, fallback_path, timeout). + Resolve target + repo_root + extra to (path_args, fallback_path, timeout). Raises FileNotFoundError/RuntimeError on invalid config. """ if target.endswith(".py"): - path = (repo_root / target).resolve() + # Target extracted from buildkite yaml may include glob patterns like + # tests/e2e/online_serving/test_*_expansion.py. Pytest does not reliably + # expand globs across platforms, so expand them here. + rel = Path(target) + if any(ch in target for ch in ["*", "?", "["]): + matches = sorted(repo_root.glob(rel.as_posix())) + if not matches: + raise FileNotFoundError(f"Pytest target glob did not match any files: {repo_root / target}") + return [str(p.resolve()) for p in matches], target.replace("\\", "/"), 90 + + path = (repo_root / rel).resolve() if not path.exists(): raise FileNotFoundError(f"Pytest target path does not exist: {path}") - return str(path), target.replace("\\", "/"), 60 + return [str(path)], target.replace("\\", "/"), 60 if not extra: raise RuntimeError(f"Failed to parse -m/--run-level from pytest line: {raw_line!r}") if not (repo_root / "tests").exists(): raise FileNotFoundError("tests/ directory not found under repo root") - return "tests/", "tests/", 120 + return ["tests/"], "tests/", 120 def collect_test_names(target: str, repo_root: Path, raw_line: str) -> list[str]: @@ -170,9 +180,9 @@ def collect_test_names(target: str, repo_root: Path, raw_line: str) -> list[str] target: path like tests/foo.py or marker string (e.g. "-m expr"). raw_line used for -m/--run-level. """ extra = _parse_extra_args_from_line(raw_line) - path_arg, _fallback, timeout_quiet = _resolve_pytest_target(target, repo_root, extra, raw_line) + path_args, _fallback, timeout_quiet = _resolve_pytest_target(target, repo_root, extra, raw_line) - cmd_quiet = [sys.executable, "-m", "pytest", path_arg, "--collect-only", "-q"] + cmd_quiet = [sys.executable, "-m", "pytest", *path_args, "--collect-only", "-q"] cmd_quiet.extend(extra) result = subprocess.run( cmd_quiet, @@ -182,8 +192,8 @@ def collect_test_names(target: str, repo_root: Path, raw_line: str) -> list[str] timeout=timeout_quiet, ) if result.returncode != 0: - raise RuntimeError(f"pytest --collect-only failed for {path_arg} with args {extra}: {result.stderr.strip()}") - print(f"pytest --collect-only success for {path_arg} with args {extra}: {result.stdout.strip()}") + raise RuntimeError(f"pytest --collect-only failed for {path_args} with args {extra}: {result.stderr.strip()}") + print(f"pytest --collect-only success for {path_args} with args {extra}: {result.stdout.strip()}") return _parse_collect_only_stdout(result.stdout or "", stderr=result.stderr or "") @@ -214,12 +224,12 @@ def get_skip_status(target: str, raw_line: str, repo_root: Path) -> set[str]: """ extra = _parse_extra_args_from_line(raw_line) try: - path_arg, _, timeout_quiet = _resolve_pytest_target(target, repo_root, extra, raw_line) + path_args, _, timeout_quiet = _resolve_pytest_target(target, repo_root, extra, raw_line) except (FileNotFoundError, RuntimeError): return set() extra_skip = _extra_with_skip_marker(extra) - cmd = [sys.executable, "-m", "pytest", path_arg, "--collect-only", "-q"] + cmd = [sys.executable, "-m", "pytest", *path_args, "--collect-only", "-q"] cmd.extend(extra_skip) try: result = subprocess.run( From 7c5159037f25418a5e43486081bf17367c117113 Mon Sep 17 00:00:00 2001 From: "rongfu.leng" Date: Thu, 12 Mar 2026 17:45:13 +0800 Subject: [PATCH 24/38] =?UTF-8?q?[Feat]=20add=20wav=20response=5Fformat=20?= =?UTF-8?q?when=20stream=20is=20true=20in=20/v1/audio/speec=E2=80=A6=20(#1?= =?UTF-8?q?819)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: rongfu.leng --- .../openai_api/test_serving_speech.py | 201 +++++++++++++++++- .../entrypoints/openai/protocol/audio.py | 4 +- .../entrypoints/openai/serving_speech.py | 90 +++++++- 3 files changed, 282 insertions(+), 13 deletions(-) diff --git a/tests/entrypoints/openai_api/test_serving_speech.py b/tests/entrypoints/openai_api/test_serving_speech.py index 7e35fec0d..e90992317 100644 --- a/tests/entrypoints/openai_api/test_serving_speech.py +++ b/tests/entrypoints/openai_api/test_serving_speech.py @@ -2,6 +2,7 @@ import asyncio import logging import os +import struct from inspect import Signature, signature from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -22,6 +23,7 @@ from vllm_omni.entrypoints.openai.audio_utils_mixin import AudioMixin from vllm_omni.entrypoints.openai.protocol.audio import CreateAudio, OpenAICreateSpeechRequest from vllm_omni.entrypoints.openai.serving_speech import ( OmniOpenAIServingSpeech, + _create_wav_header, ) from vllm_omni.outputs import OmniRequestOutput @@ -920,17 +922,20 @@ class TestStreamingProtocolValidation: """Unit tests for the stream field validators in OpenAICreateSpeechRequest.""" def test_stream_validation_errors(self): - """stream=True requires response_format='pcm' and speed=1.0.""" - with pytest.raises(ValidationError, match="response_format='pcm'"): - OpenAICreateSpeechRequest(input="Hello", stream=True, response_format="wav") + """stream=True requires response_format not in ('pcm', 'wav') and speed=1.0.""" + with pytest.raises(ValidationError, match="requires response_format not in \\('pcm', 'wav'\\)"): + OpenAICreateSpeechRequest(input="Hello", stream=True, response_format="mp3") with pytest.raises(ValidationError, match="Speed adjustment is not supported"): OpenAICreateSpeechRequest(input="Hello", stream=True, response_format="pcm", speed=2.0) def test_stream_valid(self): - """stream=True + response_format='pcm' + speed=1.0 is accepted.""" + """stream=True + response_format in ('pcm', 'wav') + speed=1.0 is accepted.""" req = OpenAICreateSpeechRequest(input="Hello", stream=True, response_format="pcm") assert req.stream is True + req = OpenAICreateSpeechRequest(input="Hello", stream=True, response_format="wav") + assert req.stream is True + def test_sse_stream_format_is_blocked(self): """stream_format='sse' is blocked.""" with pytest.raises(ValidationError, match="sse"): @@ -1086,3 +1091,191 @@ def test_api_server_create_speech_wraps_error_response_status(): assert isinstance(response, JSONResponse) assert response.status_code == 400 + + +class TestWAVHeaderGeneration: + """Unit tests for WAV header generation with placeholder values.""" + + def test_wav_header_basic_structure(self): + """Test basic WAV header structure with default parameters.""" + header = _create_wav_header(sample_rate=24000, num_channels=1, bits_per_sample=16) + + # Verify header length (should be 44 bytes) + assert len(header) == 44, f"Expected 44 bytes, got {len(header)}" + + # Parse and verify header structure + ( + chunk_id, + chunk_size, + format_type, + subchunk1_id, + subchunk1_size, + audio_format, + num_channels, + sample_rate, + byte_rate, + block_align, + bits_per_sample, + subchunk2_id, + subchunk2_size, + ) = struct.unpack("<4sI4s4sIHHIIHH4sI", header) + + # Verify RIFF header + assert chunk_id == b"RIFF", f"Expected RIFF, got {chunk_id}" + assert chunk_size == 0xFFFFFFFF, f"Expected placeholder 0xFFFFFFFF, got {chunk_size:#x}" + assert format_type == b"WAVE", f"Expected WAVE, got {format_type}" + + # Verify fmt chunk + assert subchunk1_id == b"fmt ", f"Expected 'fmt ', got {subchunk1_id}" + assert subchunk1_size == 16, f"Expected 16, got {subchunk1_size}" + assert audio_format == 1, f"Expected PCM (1), got {audio_format}" + assert num_channels == 1, f"Expected 1 channel, got {num_channels}" + assert sample_rate == 24000, f"Expected 24000 Hz, got {sample_rate}" + assert byte_rate == 48000, f"Expected 48000 byte/s, got {byte_rate}" + assert block_align == 2, f"Expected 2 bytes block align, got {block_align}" + assert bits_per_sample == 16, f"Expected 16 bits, got {bits_per_sample}" + + # Verify data chunk + assert subchunk2_id == b"data", f"Expected 'data', got {subchunk2_id}" + assert subchunk2_size == 0xFFFFFFFF, f"Expected placeholder 0xFFFFFFFF, got {subchunk2_size:#x}" + + def test_wav_header_different_sample_rates(self): + """Test WAV header with different sample rates.""" + test_cases = [ + (16000, 1, 16), + (22050, 1, 16), + (24000, 1, 16), + (44100, 1, 16), + (48000, 1, 16), + ] + + for sample_rate, num_channels, bits_per_sample in test_cases: + header = _create_wav_header(sample_rate, num_channels, bits_per_sample) + assert len(header) == 44, f"Header length mismatch for {sample_rate} Hz" + + # Parse sample rate from header + parsed_sample_rate = struct.unpack(" OmniRequestOutput: + chunk = torch.sin(torch.linspace(0, 440 * 2 * torch.pi, 24000)) + + class MockCompletionOutput: + def __init__(self, index: int = 0): + self.index = index + self.text = "" + self.token_ids = [] + self.finish_reason = "stop" + self.stop_reason = None + self.logprobs = None + + class MockRequestOutput: + def __init__(self, audio_tensor: torch.Tensor): + self.request_id = "speech-wav-stream-test" + self.outputs = [MockCompletionOutput(index=0)] + self.multimodal_output = {"audio": audio_tensor, "sr": 24000} + self.finished = finished + self.prompt_token_ids = None + self.encoder_prompt_token_ids = None + self.num_cached_tokens = None + self.prompt_logprobs = None + self.kv_transfer_params = None + + return OmniRequestOutput( + stage_id=0, + final_output_type="audio", + request_output=MockRequestOutput(audio_tensor=chunk), + finished=finished, + ) + + async def mock_generate_streaming(*args, **kwargs): + yield _make_output(finished=False) + yield _make_output(finished=True) + + mock_engine_client.generate = mocker.MagicMock(side_effect=mock_generate_streaming) + mock_engine_client.default_sampling_params_list = [{}] + mock_models = mocker.MagicMock() + mock_models.is_base_model.return_value = True + + speech_server = OmniOpenAIServingSpeech( + engine_client=mock_engine_client, + models=mock_models, + request_logger=mocker.MagicMock(), + ) + + original_create_speech = speech_server.create_speech + sig = signature(original_create_speech) + new_parameters = [p for name, p in sig.parameters.items() if name != "raw_request"] + new_sig = Signature(parameters=new_parameters, return_annotation=sig.return_annotation) + + async def awaitable_create_speech(*args, **kwargs): + return await original_create_speech(*args, **kwargs) + + awaitable_create_speech.__signature__ = new_sig + speech_server.create_speech = awaitable_create_speech + + app = FastAPI() + app.add_api_route("/v1/audio/speech", speech_server.create_speech, methods=["POST"], response_model=None) + return app + + def test_wav_streaming_success(self, wav_streaming_app): + """Test WAV format streaming returns correct content type and includes WAV header.""" + client = TestClient(wav_streaming_app) + response = client.post("/v1/audio/speech", json={"input": "Hello", "stream": True, "response_format": "wav"}) + + assert response.status_code == 200 + assert "audio/wav" in response.headers["content-type"] + assert len(response.content) > 44 # Should have WAV header + audio data + + # Verify WAV header is present + header = response.content[:44] + chunk_id = header[0:4] + format_type = header[8:12] + assert chunk_id == b"RIFF", "Should start with RIFF" + assert format_type == b"WAVE", "Should contain WAVE format" + + def test_streaming_unsupported_format_rejected(self, wav_streaming_app): + """Test that unsupported formats are rejected for streaming.""" + client = TestClient(wav_streaming_app) + + unsupported_formats = ["mp3"] + for fmt in unsupported_formats: + response = client.post("/v1/audio/speech", json={"input": "Hello", "stream": True, "response_format": fmt}) + assert response.status_code == 422 diff --git a/vllm_omni/entrypoints/openai/protocol/audio.py b/vllm_omni/entrypoints/openai/protocol/audio.py index eb32d7f05..e57743226 100644 --- a/vllm_omni/entrypoints/openai/protocol/audio.py +++ b/vllm_omni/entrypoints/openai/protocol/audio.py @@ -71,9 +71,9 @@ class OpenAICreateSpeechRequest(BaseModel): @model_validator(mode="after") def validate_streaming_constraints(self) -> "OpenAICreateSpeechRequest": if self.stream: - if self.response_format != "pcm": + if self.response_format not in ("pcm", "wav"): raise ValueError( - "Streaming (stream=true) requires response_format='pcm'. " + "Streaming (stream=true) requires response_format not in ('pcm', 'wav'). " f"Got response_format='{self.response_format}'." ) if self.speed is None: diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index 6b5656474..331adda8e 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -4,6 +4,7 @@ import json import math import os import re +import struct import time from pathlib import Path from typing import Any @@ -48,6 +49,47 @@ _TTS_MAX_NEW_TOKENS_MIN = 1 _TTS_MAX_NEW_TOKENS_MAX = 4096 +def _create_wav_header(sample_rate: int, num_channels: int = 1, bits_per_sample: int = 16) -> bytes: + """Create a WAV header with placeholder size values for streaming. + + Uses 0xFFFFFFFF as placeholder for data size fields, which is accepted + by most audio clients and matches OpenAI's streaming WAV implementation. + + Args: + sample_rate: Audio sample rate in Hz + num_channels: Number of audio channels (1 for mono, 2 for stereo) + bits_per_sample: Bits per sample (typically 16) + + Returns: + 44-byte WAV header as bytes + """ + byte_rate = sample_rate * num_channels * bits_per_sample // 8 + block_align = num_channels * bits_per_sample // 8 + + # Use 0xFFFFFFFF as placeholder for unknown size (streaming) + placeholder_size = 0xFFFFFFFF + + # ref https://docs.fileformat.com/audio/wav/ + header = struct.pack( + "<4sI4s4sIHHIIHH4sI", + b"RIFF", # ChunkID + placeholder_size, # ChunkSize (placeholder) + b"WAVE", # Format + b"fmt ", # Subchunk1ID + 16, # Subchunk1Size (16 for PCM) + 1, # AudioFormat (1 for PCM) + num_channels, # NumChannels + sample_rate, # SampleRate + byte_rate, # ByteRate + block_align, # BlockAlign + bits_per_sample, # BitsPerSample + b"data", # Subchunk2ID + placeholder_size, # Subchunk2Size (placeholder) + ) + + return header + + def _sanitize_filename(filename: str) -> str: """Sanitize filename to prevent path traversal attacks. @@ -575,8 +617,8 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): wav_np = np.mean(wav_np, axis=-1) return wav_np.tolist(), int(sr) - async def _generate_pcm_chunks(self, generator, request_id: str): - """Generate PCM audio chunks for streaming response. + async def _generate_audio_chunks(self, generator, request_id: str, response_format: str = "pcm"): + """Generate audio chunks for streaming response. Handles two audio output modes from the engine: - Cumulative mode (list): Engine returns growing list of chunks; @@ -587,12 +629,15 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): Args: generator: Async generator from the engine request_id: Request identifier for logging + response_format: Audio format (pcm or wav) Yields: - Raw PCM bytes for each audio chunk + Raw audio bytes for each chunk (with WAV header for first chunk if wav format) """ prev_count = 0 sample_rate_val = 24000 + first_chunk = True + try: async for res in generator: audio_output, audio_key = self._extract_audio_output(res) @@ -623,6 +668,18 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): ) if chunk_np.ndim > 1: chunk_np = chunk_np.squeeze() + # For WAV format, emit header before first audio chunk + if response_format == "wav" and first_chunk: + # Assert that sample rate has been set from chunk metadata (not just default) + # This ensures the WAV header contains the correct sample rate + assert sr_raw is not None, ( + "First audio chunk must include sample rate metadata for WAV streaming" + ) + wav_header = _create_wav_header(sample_rate=sample_rate_val, num_channels=1, bits_per_sample=16) + yield wav_header + first_chunk = False + + # Convert audio to PCM bytes audio_obj = CreateAudio( audio_tensor=chunk_np, sample_rate=sample_rate_val, @@ -833,8 +890,9 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): - ref_text: Transcript of reference audio (Base task) - x_vector_only_mode: Use speaker embedding only (Base task) - Streaming is supported via stream=True with response_format='pcm'. - Each Code2Wav chunk is yielded as raw PCM bytes as soon as it is decoded. + Streaming is supported via stream=True with response_format='pcm' or 'wav'. + Each Code2Wav chunk is yielded as raw audio bytes as soon as it is decoded. + For WAV format, a header with placeholder size values is emitted first. """ error_check_ret = await self._check_model(request) if error_check_ret is not None: @@ -843,10 +901,28 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): try: if request.stream: + # Determine response format and media type for streaming + response_format = (request.response_format or "wav").lower() + + # Only pcm and wav support streaming without post-processing + if response_format not in ["pcm", "wav"]: + return self.create_error_response( + f"Streaming is only supported for 'pcm' and 'wav' formats. " + f"Got '{response_format}'. For other formats, use stream=False." + ) + + # Check if speed adjustment is requested (not compatible with streaming) + if request.speed is not None and request.speed != 1.0: + return self.create_error_response( + "Streaming is not supported with speed adjustment. " + "Use stream=False or remove the speed parameter." + ) + + media_type = "audio/wav" if response_format == "wav" else "audio/pcm" request_id, generator, _ = await self._prepare_speech_generation(request) return StreamingResponse( - self._generate_pcm_chunks(generator, request_id), - media_type="audio/pcm", + self._generate_audio_chunks(generator, request_id, response_format), + media_type=media_type, ) audio_bytes, media_type = await self._generate_audio_bytes(request) From a52867abde4fae10203c5d3d649eda77824376d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=AA=E5=BF=97=E9=B9=8F?= Date: Thu, 12 Mar 2026 19:19:57 +0800 Subject: [PATCH 25/38] [BugFix]: Revert #1582 (#1842) Signed-off-by: princepride --- .../diffusion/worker/diffusion_worker.py | 66 ------------------- vllm_omni/entrypoints/omni.py | 60 ----------------- 2 files changed, 126 deletions(-) diff --git a/vllm_omni/diffusion/worker/diffusion_worker.py b/vllm_omni/diffusion/worker/diffusion_worker.py index 79a1a9ef9..1b9dc5667 100644 --- a/vllm_omni/diffusion/worker/diffusion_worker.py +++ b/vllm_omni/diffusion/worker/diffusion_worker.py @@ -9,12 +9,10 @@ to DiffusionModelRunner. """ import gc -import json import multiprocessing as mp import os from collections.abc import Iterable from contextlib import AbstractContextManager, nullcontext -from pathlib import Path from typing import Any import torch @@ -47,16 +45,6 @@ from vllm_omni.worker.gpu_memory_utils import get_process_gpu_memory logger = init_logger(__name__) -MODEL_PARAM_COUNTS = { - "bagel": 13.5e9, # BAGEL-7B-MoT - "flux": 12.0e9, # FLUX.1-dev - "default": 10.0e9, -} - -# 2.5 GiB is the empirical peak activation footprint (intermediate tensors + -# attention workspace) for models like Bagel-7B/Flux at 1024x1024 resolution. -ACTIVATION_MEMORY_MULTIPLIER = 2.5 - class DiffusionWorker: """ @@ -71,60 +59,6 @@ class DiffusionWorker: delegated to DiffusionModelRunner. """ - @staticmethod - def predict_resource_usage(od_config: OmniDiffusionConfig) -> dict[str, float]: - from vllm.utils.mem_utils import GiB_bytes - - total_params = 0 - estimate_source = "fallback" - try: - model_path = Path(getattr(od_config, "model", "")) - index_file = model_path / "model.safetensors.index.json" - if index_file.exists(): - with open(index_file) as f: - data = json.load(f) - # metadata.total_size - total_params = int(data.get("metadata", {}).get("total_size", 0)) - if total_params > 0: - estimate_source = "safetensors index" - except Exception as e: - logger.debug(f"Failed to parse safetensors metadata: {e}") - if total_params == 0: - m_name = str(getattr(od_config, "model", "")).lower() - if "bagel" in m_name: - total_params = MODEL_PARAM_COUNTS["bagel"] - estimate_source = "bagel" - elif "flux" in m_name: - total_params = MODEL_PARAM_COUNTS["flux"] - estimate_source = "flux" - else: - total_params = MODEL_PARAM_COUNTS["default"] - estimate_source = "default" - logger.info(f"VRAM Quota: Estimated {total_params / 1e9:.2f}B params using {estimate_source} logic") - - dtype = getattr(od_config, "dtype", torch.bfloat16) - dtype_str = str(dtype).lower() - # Calculate the number of bytes per parameter - # based on different levels of precision. - if "int4" in dtype_str: - bytes_per_param = 0.5 - elif any(kw in dtype_str for kw in ["float8", "int8"]): - bytes_per_param = 1 - elif any(kw in dtype_str for kw in ["float16", "bfloat16", "half"]): - bytes_per_param = 2 - elif any(kw in dtype_str for kw in ["float32", "int32"]): - bytes_per_param = 4 - else: - bytes_per_param = 4 - static_gb = (total_params * bytes_per_param) / GiB_bytes - h, w = getattr(od_config, "height", 1024), getattr(od_config, "width", 1024) - dynamic_gb = ACTIVATION_MEMORY_MULTIPLIER * (h * w / (1024 * 1024)) - return { - "static_gb": round(static_gb, 2), - "dynamic_gb": round(dynamic_gb, 2), - "total_gb": round(static_gb + dynamic_gb, 2), - } - def __init__( self, local_rank: int, diff --git a/vllm_omni/entrypoints/omni.py b/vllm_omni/entrypoints/omni.py index f96b4e9ea..c414b972b 100644 --- a/vllm_omni/entrypoints/omni.py +++ b/vllm_omni/entrypoints/omni.py @@ -313,63 +313,6 @@ class OmniBase: return config_path, stage_configs - def _coordinate_vram_resources(self) -> None: - """ - Coordinate VRAM resource reservations across stages based on their type and configs. - Note: Current implementation supports per-device isolation. Advanced multi-GPU - scenarios like TP or PP are planned as sleep mode ack Test Scenario. - """ - import torch - from vllm.utils.mem_utils import GiB_bytes - - from vllm_omni.diffusion.worker.diffusion_worker import DiffusionWorker - - # Device ID - reserved_gb_per_device: dict[int, float] = {} - active_configs = self.stage_configs - if hasattr(self, "_single_stage_id") and self._single_stage_id is not None: - active_configs = [cfg for cfg in self.stage_configs if cfg.stage_id == self._single_stage_id] - for cfg in active_configs: - s_type = getattr(cfg, "stage_type", None) - if s_type == "diffusion": - prediction = DiffusionWorker.predict_resource_usage(cfg.engine_args) - device_str = getattr(cfg.runtime, "devices", "0") - try: - devices = [int(d.strip()) for d in device_str.split(",")] - except (ValueError, AttributeError): - devices = [0] - for d_id in devices: - reserved_gb_per_device[d_id] = reserved_gb_per_device.get(d_id, 0.0) + prediction["total_gb"] - logger.info( - f"[Coordinator] Stage-{cfg.stage_id} ({s_type.capitalize()}) " - f"on devices {devices} predicted budget: {prediction['total_gb']:.2f} GiB" - ) - if not torch.cuda.is_available(): - # TODO: Add support for other accelerators (NPU/XPU) - # as their specific memory management APIs are integrated. - return - for cfg in active_configs: - if getattr(cfg, "stage_type", None) == "llm": - # Get the master device ID where the LLM is located - llm_device_str = getattr(cfg.runtime, "devices", "0") - try: - target_device = int(llm_device_str.split(",")[0].strip()) - except (ValueError, AttributeError): - target_device = 0 - # The physical total of the card containing LLM - physical_vram_gb = torch.cuda.get_device_properties(target_device).total_memory / GiB_bytes - total_reserved_on_this_device = reserved_gb_per_device.get(target_device, 0.0) - if total_reserved_on_this_device > 0: - original_util = cfg.engine_args.get("gpu_memory_utilization", 0.9) - reserved_util_ratio = total_reserved_on_this_device / physical_vram_gb - adjusted_util = min(0.95, original_util + reserved_util_ratio) - cfg.engine_args["gpu_memory_utilization"] = round(adjusted_util, 3) - logger.info( - f"[Coordinator] LLM Stage-{cfg.stage_id} on Device {target_device} dynamic boost: " - f"{original_util} -> {cfg.engine_args['gpu_memory_utilization']} " - f"(Compensating {reserved_util_ratio:.2f} ratio for resource domain isolation)" - ) - def _initialize_stages(self, model: str, kwargs: dict[str, Any]) -> None: """Initialize stage list management.""" self._inline_diffusion = False @@ -392,9 +335,6 @@ class OmniBase: # Resolve stage configs shared by orchestrator/headless paths. self.config_path, self.stage_configs = self._resolve_stage_configs(model, kwargs) - # This ensures all engines receive the corrected gpu_memory_utilization through engine_args. - self._coordinate_vram_resources() - # Initialize connectors self.omni_transfer_config, self.connectors = initialize_orchestrator_connectors( self.config_path, worker_backend=worker_backend, shm_threshold_bytes=shm_threshold_bytes From fe7e8770db43a845c4ea0407ddc61f17ba3a7f63 Mon Sep 17 00:00:00 2001 From: JohnJan Date: Thu, 12 Mar 2026 19:53:47 +0800 Subject: [PATCH 26/38] [Feature]: support Flux.2-dev cache_dit (#1814) Co-authored-by: wuzhongjian Co-authored-by: Gao Han --- docs/user_guide/diffusion_acceleration.md | 2 +- .../diffusion/cache/cache_dit_backend.py | 75 +++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/docs/user_guide/diffusion_acceleration.md b/docs/user_guide/diffusion_acceleration.md index ef4b39d0a..7f32e3eff 100644 --- a/docs/user_guide/diffusion_acceleration.md +++ b/docs/user_guide/diffusion_acceleration.md @@ -69,7 +69,7 @@ The following table shows which models are currently supported by each accelerat | **FLUX.1-dev** | `black-forest-labs/FLUX.1-dev` | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | | **NextStep-1.1** | `stepfun-ai/NextStep-1.1` | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ | | **FLUX.2-klein** | `black-forest-labs/FLUX.2-klein-4B` | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | -| **FLUX.2-dev** | `black-forest-labs/FLUX.2-dev` | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | +| **FLUX.2-dev** | `black-forest-labs/FLUX.2-dev` | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ### VideoGen diff --git a/vllm_omni/diffusion/cache/cache_dit_backend.py b/vllm_omni/diffusion/cache/cache_dit_backend.py index 1c98d0724..d7165eda2 100644 --- a/vllm_omni/diffusion/cache/cache_dit_backend.py +++ b/vllm_omni/diffusion/cache/cache_dit_backend.py @@ -984,6 +984,80 @@ def enable_cache_for_bagel(pipeline: Any, cache_config: Any) -> Callable[[int], return refresh_cache_context +def enable_cache_for_flux2(pipeline: Any, cache_config: Any) -> Callable[[int], None]: + """Enable cache-dit for Flux.2-dev pipeline. + + Args: + pipeline: The Flux2 pipeline instance. + cache_config: DiffusionCacheConfig instance with cache configuration. + Returns: + A refresh function that can be called with a new ``num_inference_steps`` + to update the cache context for the pipeline. + """ + # Build DBCacheConfig for transformer + db_cache_config = _build_db_cache_config(cache_config) + + calibrator = None + if cache_config.enable_taylorseer: + taylorseer_order = cache_config.taylorseer_order + calibrator = TaylorSeerCalibratorConfig(taylorseer_order=taylorseer_order) + logger.info(f"TaylorSeer enabled with order={taylorseer_order}") + + # Build ParamsModifier for transformer + modifier = ParamsModifier( + cache_config=db_cache_config, + calibrator_config=calibrator, + ) + + logger.info( + f"Enabling cache-dit on Flux transformer with BlockAdapter: " + f"Fn={db_cache_config.Fn_compute_blocks}, " + f"Bn={db_cache_config.Bn_compute_blocks}, " + f"W={db_cache_config.max_warmup_steps}, " + ) + + # Enable cache-dit using BlockAdapter for transformer + cache_dit.enable_cache( + ( + BlockAdapter( + transformer=pipeline.transformer, + blocks=[ + pipeline.transformer.transformer_blocks, + pipeline.transformer.single_transformer_blocks, + ], + forward_pattern=[ForwardPattern.Pattern_1, ForwardPattern.Pattern_2], + params_modifiers=[modifier], + ) + ), + cache_config=db_cache_config, + ) + + def refresh_cache_context(pipeline: Any, num_inference_steps: int, verbose: bool = True) -> None: + """Refresh cache context for the transformer with new num_inference_steps. + + Args: + pipeline: The Flux2 pipeline instance. + num_inference_steps: New number of inference steps. + """ + if cache_config.scm_steps_mask_policy is None: + cache_dit.refresh_context(pipeline.transformer, num_inference_steps=num_inference_steps, verbose=verbose) + else: + cache_dit.refresh_context( + pipeline.transformer, + cache_config=DBCacheConfig().reset( + num_inference_steps=num_inference_steps, + steps_computation_mask=cache_dit.steps_mask( + mask_policy=cache_config.scm_steps_mask_policy, + total_steps=num_inference_steps, + ), + steps_computation_policy=cache_config.scm_steps_policy, + ), + verbose=verbose, + ) + + return refresh_cache_context + + # Register custom cache-dit enablers after function definitions CUSTOM_DIT_ENABLERS.update( { @@ -998,6 +1072,7 @@ CUSTOM_DIT_ENABLERS.update( "LTX2Pipeline": enable_cache_for_ltx2, "LTX2ImageToVideoPipeline": enable_cache_for_ltx2, "BagelPipeline": enable_cache_for_bagel, + "Flux2Pipeline": enable_cache_for_flux2, } ) From ff3365f6a2166c71284ba4a4513dda71835ab8ac Mon Sep 17 00:00:00 2001 From: Hongsheng Liu Date: Thu, 12 Mar 2026 20:27:42 +0800 Subject: [PATCH 27/38] [skip ci] update readme slides link (#1850) Signed-off-by: hsliu_ustc Co-authored-by: hsliu_ustc --- README.md | 2 +- docs/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 74f61b5fd..81d24a1a3 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Easy, fast, and cheap omni-modality model serving for everyone

-| Documentation | User Forum | Developer Slack | WeChat | Paper | Slides | +| Documentation | User Forum | Developer Slack | WeChat | Paper | Slides |

diff --git a/docs/README.md b/docs/README.md index 363f579c9..66fc8ef46 100644 --- a/docs/README.md +++ b/docs/README.md @@ -60,5 +60,5 @@ vLLM-Omni seamlessly supports most popular open-source models on HuggingFace, in For more information, checkout the following: -- [vllm-omni architecture design and recent roadmaps](https://docs.google.com/presentation/d/1qv4qMW1rKAqDREMXiUDLIgqqHQe7TDPj/edit?usp=sharing&ouid=110473603432222024453&rtpof=true&sd=true) +- [vllm-omni architecture design and recent roadmaps](https://docs.google.com/presentation/d/1XJWgv79lORl8rbaVvp2d5Sqs6ZEBgAgj/edit?slide=id.p1#slide=id.p1) - [vllm-omni announcement blogpost](https://blog.vllm.ai/2025/11/30/vllm-omni.html) From 28dd1a699da0be3c56e8e34d66ffe106437613fc Mon Sep 17 00:00:00 2001 From: Jiaping Wu <53215702+ElleElleWu@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:49:17 +0800 Subject: [PATCH 28/38] [Model] Extend NPU support for HunyuanImage3 Diffusion Model (#1689) Signed-off-by: ElleElleWu <1608928702@qq.com> Signed-off-by: gcanlin Co-authored-by: skf1999 <13234016272@163.com> Co-authored-by: Just-it <1161406585@qq.com> Co-authored-by: Semmer2 Co-authored-by: gcanlin --- docs/models/supported_models.md | 1 + .../hunyuan_image_3/test_hunyuan_fused_moe.py | 112 ++++++++++++++++++ tests/diffusion/test_data_is_moe.py | 78 ++++++++++++ vllm_omni/diffusion/data.py | 6 +- .../models/hunyuan_image_3/__init__.py | 7 +- .../hunyuan_image_3/hunyuan_fused_moe.py | 56 +++++++++ .../hunyuan_image_3_transformer.py | 22 +--- vllm_omni/platforms/interface.py | 11 ++ vllm_omni/platforms/npu/models/__init__.py | 2 + .../platforms/npu/models/hunyuan_fused_moe.py | 108 +++++++++++++++++ vllm_omni/platforms/npu/platform.py | 19 +++ 11 files changed, 395 insertions(+), 27 deletions(-) create mode 100644 tests/diffusion/models/hunyuan_image_3/test_hunyuan_fused_moe.py create mode 100644 tests/diffusion/test_data_is_moe.py create mode 100644 vllm_omni/diffusion/models/hunyuan_image_3/hunyuan_fused_moe.py create mode 100644 vllm_omni/platforms/npu/models/__init__.py create mode 100644 vllm_omni/platforms/npu/models/hunyuan_fused_moe.py diff --git a/docs/models/supported_models.md b/docs/models/supported_models.md index 67a49c575..52e75989d 100644 --- a/docs/models/supported_models.md +++ b/docs/models/supported_models.md @@ -65,6 +65,7 @@ th { |--------------|--------|-------------------| | `Qwen3OmniMoeForConditionalGeneration` | Qwen3-Omni | `Qwen/Qwen3-Omni-30B-A3B-Instruct` | | `Qwen2_5OmniForConditionalGeneration` | Qwen2.5-Omni | `Qwen/Qwen2.5-Omni-7B`, `Qwen/Qwen2.5-Omni-3B`| +| `HunyuanImage3ForCausalMM` | HunyuanImage3.0 (DiT-only) | `tencent/HunyuanImage-3.0`, `tencent/HunyuanImage-3.0-Instruct` | | `QwenImagePipeline` | Qwen-Image | `Qwen/Qwen-Image` | | `QwenImagePipeline` | Qwen-Image-2512 | `Qwen/Qwen-Image-2512` | | `QwenImageEditPipeline` | Qwen-Image-Edit | `Qwen/Qwen-Image-Edit` | diff --git a/tests/diffusion/models/hunyuan_image_3/test_hunyuan_fused_moe.py b/tests/diffusion/models/hunyuan_image_3/test_hunyuan_fused_moe.py new file mode 100644 index 000000000..2aa1adf44 --- /dev/null +++ b/tests/diffusion/models/hunyuan_image_3/test_hunyuan_fused_moe.py @@ -0,0 +1,112 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Unit tests for HunyuanFusedMoE (Support HunyuanImage3 Diffusion Model, 5a779b4).""" + +import pytest + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class TestHunyuanFusedMoEPlatformDispatch: + """Test platform dispatch via platform qualname hooks.""" + + def test_default_platform_uses_default_impl_qualname(self, mocker): + """HunyuanFusedMoE should resolve the impl class from the platform hook.""" + import vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe as hunyuan_moe + + mock_platform = mocker.MagicMock() + mock_platform.get_diffusion_model_impl_qualname.return_value = ( + "vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe.HunyuanFusedMoEDefault" + ) + + mocker.patch.object( + hunyuan_moe, + "current_omni_platform", + mock_platform, + ) + mock_resolve = mocker.patch.object(hunyuan_moe, "resolve_obj_by_qualname") + mock_impl = mocker.MagicMock() + mock_resolve.return_value = mock_impl + + from vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe import ( + HunyuanFusedMoE, + ) + + HunyuanFusedMoE(prefix="") + + mock_platform.prepare_diffusion_op_runtime.assert_called_once_with("hunyuan_fused_moe") + mock_platform.get_diffusion_model_impl_qualname.assert_called_once_with("hunyuan_fused_moe") + mock_resolve.assert_called_once_with( + "vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe.HunyuanFusedMoEDefault" + ) + mock_impl.assert_called_once_with(prefix="") + + +class TestHunyuanFusedMoEFactory: + """Test HunyuanFusedMoE factory __new__ and make_expert_params_mapping delegation.""" + + def test_new_delegates_to_impl_class(self, mocker): + """HunyuanFusedMoE(prefix=..., **kwargs) should instantiate and return impl instance.""" + import vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe as hunyuan_moe + + class MockImpl: + def __init__(self, *, prefix: str = "", **kwargs): + self.prefix = prefix + self.kwargs = kwargs + + mock_platform = mocker.MagicMock() + mock_platform.get_diffusion_model_impl_qualname.return_value = "mock.impl.Qualname" + mocker.patch.object(hunyuan_moe, "current_omni_platform", mock_platform) + + mock_impl_class = mocker.MagicMock(return_value=MockImpl(prefix="test", a=1)) + mocker.patch.object(hunyuan_moe, "resolve_obj_by_qualname", return_value=mock_impl_class) + + from vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe import ( + HunyuanFusedMoE, + ) + + result = HunyuanFusedMoE(prefix="test", a=1) + + assert isinstance(result, MockImpl) + assert result.prefix == "test" + assert result.kwargs == {"a": 1} + mock_platform.prepare_diffusion_op_runtime.assert_called_once_with("hunyuan_fused_moe") + mock_platform.get_diffusion_model_impl_qualname.assert_called_once_with("hunyuan_fused_moe") + mock_impl_class.assert_called_once_with(prefix="test", a=1) + + def test_make_expert_params_mapping_delegates_to_impl(self, mocker): + """make_expert_params_mapping should delegate to impl class method.""" + import vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe as hunyuan_moe + + expected_mapping = [("a", "b", 0, "c")] + mock_platform = mocker.MagicMock() + mock_platform.get_diffusion_model_impl_qualname.return_value = "mock.impl.Qualname" + mocker.patch.object(hunyuan_moe, "current_omni_platform", mock_platform) + + mock_impl_class = mocker.MagicMock() + mock_impl_class.make_expert_params_mapping = mocker.MagicMock(return_value=expected_mapping) + mocker.patch.object(hunyuan_moe, "resolve_obj_by_qualname", return_value=mock_impl_class) + + from vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe import ( + HunyuanFusedMoE, + ) + + result = HunyuanFusedMoE.make_expert_params_mapping( + model=None, + ckpt_gate_proj_name="gate", + ckpt_down_proj_name="down", + ckpt_up_proj_name="up", + num_experts=4, + num_redundant_experts=0, + ) + + assert result == expected_mapping + mock_platform.get_diffusion_model_impl_qualname.assert_called_once_with("hunyuan_fused_moe") + mock_impl_class.make_expert_params_mapping.assert_called_once_with( + None, + ckpt_gate_proj_name="gate", + ckpt_down_proj_name="down", + ckpt_up_proj_name="up", + num_experts=4, + num_redundant_experts=0, + ) diff --git a/tests/diffusion/test_data_is_moe.py b/tests/diffusion/test_data_is_moe.py new file mode 100644 index 000000000..25fa59ef1 --- /dev/null +++ b/tests/diffusion/test_data_is_moe.py @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Unit tests for OmniDiffusionConfig.is_moe (fix is_moe type and threshold, 6663c0b).""" + +import pytest + +from vllm_omni.diffusion.data import OmniDiffusionConfig, TransformerConfig + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class TestOmniDiffusionConfigIsMoE: + """Tests for OmniDiffusionConfig.is_moe property. + + Covers commit 6663c0b: fix is_moe type and threshold + - num_experts must be (list, tuple, int); otherwise return False. + - Threshold: is_moe is True when num_experts > 0 (not > 1). + """ + + def test_is_moe_missing_num_experts_returns_false(self): + """When num_experts is absent, is_moe should be False.""" + tf_config = TransformerConfig.from_dict({}) + config = OmniDiffusionConfig(model="test", tf_model_config=tf_config) + assert config.is_moe is False + + def test_is_moe_none_num_experts_returns_false(self): + """When num_experts is explicitly None (e.g. in params), is_moe should be False.""" + tf_config = TransformerConfig.from_dict({"num_experts": None}) + config = OmniDiffusionConfig(model="test", tf_model_config=tf_config) + assert config.is_moe is False + + def test_is_moe_non_allowed_type_returns_false(self): + """When num_experts is not int/list/tuple (e.g. str), is_moe should be False.""" + tf_config = TransformerConfig.from_dict({"num_experts": "2"}) + config = OmniDiffusionConfig(model="test", tf_model_config=tf_config) + assert config.is_moe is False + + def test_is_moe_int_zero_returns_false(self): + """num_experts int 0 should yield is_moe False (threshold > 0).""" + tf_config = TransformerConfig.from_dict({"num_experts": 0}) + config = OmniDiffusionConfig(model="test", tf_model_config=tf_config) + assert config.is_moe is False + + def test_is_moe_int_one_returns_true(self): + """num_experts int 1 should yield is_moe True (threshold > 0, not > 1).""" + tf_config = TransformerConfig.from_dict({"num_experts": 1}) + config = OmniDiffusionConfig(model="test", tf_model_config=tf_config) + assert config.is_moe is True + + def test_is_moe_int_gt_one_returns_true(self): + """num_experts int > 1 should yield is_moe True.""" + tf_config = TransformerConfig.from_dict({"num_experts": 2}) + config = OmniDiffusionConfig(model="test", tf_model_config=tf_config) + assert config.is_moe is True + + def test_is_moe_list_all_zero_returns_false(self): + """num_experts list with all <= 0 should yield is_moe False.""" + tf_config = TransformerConfig.from_dict({"num_experts": [0]}) + config = OmniDiffusionConfig(model="test", tf_model_config=tf_config) + assert config.is_moe is False + + def test_is_moe_list_has_positive_returns_true(self): + """num_experts list with any int > 0 should yield is_moe True.""" + tf_config = TransformerConfig.from_dict({"num_experts": [0, 1]}) + config = OmniDiffusionConfig(model="test", tf_model_config=tf_config) + assert config.is_moe is True + + def test_is_moe_tuple_has_positive_returns_true(self): + """num_experts tuple with any int > 0 should yield is_moe True.""" + tf_config = TransformerConfig.from_dict({"num_experts": (0, 2)}) + config = OmniDiffusionConfig(model="test", tf_model_config=tf_config) + assert config.is_moe is True + + def test_is_moe_list_non_int_ignored(self): + """num_experts list with only non-int entries should yield is_moe False.""" + tf_config = TransformerConfig.from_dict({"num_experts": ["a", 0.0]}) + config = OmniDiffusionConfig(model="test", tf_model_config=tf_config) + assert config.is_moe is False diff --git a/vllm_omni/diffusion/data.py b/vllm_omni/diffusion/data.py index 38366469e..634cb3294 100644 --- a/vllm_omni/diffusion/data.py +++ b/vllm_omni/diffusion/data.py @@ -463,11 +463,13 @@ class OmniDiffusionConfig: @property def is_moe(self) -> bool: num_experts = self.tf_model_config.get("num_experts", None) + if not isinstance(num_experts, (list, tuple, int)): + return False if isinstance(num_experts, int): - return num_experts > 1 + return num_experts > 0 if isinstance(num_experts, (list, tuple)): - return any(isinstance(n, int) and n > 1 for n in num_experts) + return any(isinstance(n, int) and n > 0 for n in num_experts) return False diff --git a/vllm_omni/diffusion/models/hunyuan_image_3/__init__.py b/vllm_omni/diffusion/models/hunyuan_image_3/__init__.py index 98a3ac07b..cbc6a8ad1 100644 --- a/vllm_omni/diffusion/models/hunyuan_image_3/__init__.py +++ b/vllm_omni/diffusion/models/hunyuan_image_3/__init__.py @@ -2,6 +2,7 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project """Hunyuan Image 3 diffusion model components.""" +from vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe import HunyuanFusedMoE from vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_image_3_transformer import ( HunyuanImage3Model, HunyuanImage3Text2ImagePipeline, @@ -10,8 +11,4 @@ from vllm_omni.diffusion.models.hunyuan_image_3.pipeline_hunyuan_image_3 import HunyuanImage3Pipeline, ) -__all__ = [ - "HunyuanImage3Pipeline", - "HunyuanImage3Model", - "HunyuanImage3Text2ImagePipeline", -] +__all__ = ["HunyuanImage3Pipeline", "HunyuanImage3Model", "HunyuanImage3Text2ImagePipeline", "HunyuanFusedMoE"] diff --git a/vllm_omni/diffusion/models/hunyuan_image_3/hunyuan_fused_moe.py b/vllm_omni/diffusion/models/hunyuan_image_3/hunyuan_fused_moe.py new file mode 100644 index 000000000..5ada5ceb8 --- /dev/null +++ b/vllm_omni/diffusion/models/hunyuan_image_3/hunyuan_fused_moe.py @@ -0,0 +1,56 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from typing import Any + +from vllm.model_executor.layers.fused_moe import SharedFusedMoE +from vllm.utils.import_utils import resolve_obj_by_qualname + +from vllm_omni.platforms import current_omni_platform + + +class HunyuanFusedMoEDefault(SharedFusedMoE): + def __init__(self, *, prefix: str = "", **kwargs: Any) -> None: + super().__init__(prefix=prefix, **kwargs) + self._prefix = prefix + self._init_hook_handle = self.register_forward_pre_hook(self._initialize_kernel_hook, with_kwargs=True) + + def _initialize_kernel_hook(self, module: Any, args: Any, kwargs: Any) -> None: + if self.quant_method: + self.quant_method.process_weights_after_loading(self) + self._init_hook_handle.remove() + + def forward(self, hidden_states: Any, router_logits: Any) -> Any: + return super().forward(hidden_states, router_logits) + + +class HunyuanFusedMoE: + def __new__(cls, *, prefix: str = "", **kwargs: Any) -> Any: + op_name = "hunyuan_fused_moe" + current_omni_platform.prepare_diffusion_op_runtime(op_name) + impl = resolve_obj_by_qualname( + current_omni_platform.get_diffusion_model_impl_qualname(op_name), + ) + return impl(prefix=prefix, **kwargs) + + @classmethod + def make_expert_params_mapping( + cls, + model: Any, + ckpt_gate_proj_name: str, + ckpt_down_proj_name: str, + ckpt_up_proj_name: str, + num_experts: int, + num_redundant_experts: int = 0, + ) -> list[tuple[str, str, int, str]]: + impl = resolve_obj_by_qualname( + current_omni_platform.get_diffusion_model_impl_qualname("hunyuan_fused_moe"), + ) + return impl.make_expert_params_mapping( + model, + ckpt_gate_proj_name=ckpt_gate_proj_name, + ckpt_down_proj_name=ckpt_down_proj_name, + ckpt_up_proj_name=ckpt_up_proj_name, + num_experts=num_experts, + num_redundant_experts=num_redundant_experts, + ) diff --git a/vllm_omni/diffusion/models/hunyuan_image_3/hunyuan_image_3_transformer.py b/vllm_omni/diffusion/models/hunyuan_image_3/hunyuan_image_3_transformer.py index 0c2f9e290..a89931550 100644 --- a/vllm_omni/diffusion/models/hunyuan_image_3/hunyuan_image_3_transformer.py +++ b/vllm_omni/diffusion/models/hunyuan_image_3/hunyuan_image_3_transformer.py @@ -32,10 +32,8 @@ from transformers.modeling_utils import PreTrainedModel from vllm.config import CacheConfig from vllm.distributed import ( get_tensor_model_parallel_world_size, - tensor_model_parallel_all_reduce, ) from vllm.model_executor.layers.activation import SiluAndMul -from vllm.model_executor.layers.fused_moe import SharedFusedMoE from vllm.model_executor.layers.layernorm import RMSNorm from vllm.model_executor.layers.linear import ( MergedColumnParallelLinear, @@ -63,6 +61,7 @@ from vllm_omni.diffusion.attention.layer import Attention from vllm_omni.diffusion.distributed.parallel_state import get_pp_group from vllm_omni.diffusion.distributed.utils import get_local_device from vllm_omni.diffusion.layers.rope import RotaryEmbedding +from vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe import HunyuanFusedMoE logger = logging.getLogger(__name__) @@ -1417,7 +1416,7 @@ class HunYuanSparseMoeBlock(nn.Module): final_hidden_states = final_hidden_states[0] + final_hidden_states[1] if self.tp_size > 1: - final_hidden_states = tensor_model_parallel_all_reduce(final_hidden_states) + final_hidden_states = self.experts.maybe_all_reduce_tensor_model_parallel(final_hidden_states) return final_hidden_states.view(orig_shape) @@ -1565,22 +1564,6 @@ class HunYuanAttention(nn.Module): return output, None, past_key_value -class HunyuanFusedMoE(SharedFusedMoE): - def __init__(self, *, prefix: str = "", **kwargs): - super().__init__(prefix=prefix, **kwargs) - self._prefix = prefix - - self._init_hook_handle = self.register_forward_pre_hook(self._initialize_kernel_hook, with_kwargs=True) - - def _initialize_kernel_hook(self, module, args, kwargs): - if self.quant_method: - self.quant_method.process_weights_after_loading(self) - self._init_hook_handle.remove() - - def forward(self, hidden_states, router_logits): - return super().forward(hidden_states, router_logits) - - class HunyuanImage3DecoderLayer(nn.Module): def __init__(self, config: HunyuanImage3Config, layer_idx: int, prefix: str = ""): super().__init__() @@ -2454,7 +2437,6 @@ class HunyuanImage3Text2ImagePipeline(DiffusionPipeline): callback_outputs = callback_on_step_end(self, i, t, callback_kwargs) latents = callback_outputs.pop("latents", latents) - # call the callback, if provided if i == len(timesteps) - 1 or ((i + 1) > num_warmup_steps and (i + 1) % self.scheduler.order == 0): progress_bar.update() diff --git a/vllm_omni/platforms/interface.py b/vllm_omni/platforms/interface.py index f90c69e3a..314cb3219 100644 --- a/vllm_omni/platforms/interface.py +++ b/vllm_omni/platforms/interface.py @@ -2,6 +2,7 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project from enum import Enum +from typing import Any import torch from vllm.platforms import Platform @@ -52,6 +53,16 @@ class OmniPlatform(Platform): def get_default_stage_config_path(cls) -> str: raise NotImplementedError + @classmethod + def get_diffusion_model_impl_qualname(cls, op_name: str) -> str: + if op_name == "hunyuan_fused_moe": + return "vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe.HunyuanFusedMoEDefault" + raise NotImplementedError(f"Unsupported diffusion model op: {op_name}") + + @classmethod + def prepare_diffusion_op_runtime(cls, op_name: str, **kwargs: Any) -> None: + return None + @classmethod def get_diffusion_attn_backend_cls( cls, diff --git a/vllm_omni/platforms/npu/models/__init__.py b/vllm_omni/platforms/npu/models/__init__.py new file mode 100644 index 000000000..208f01a7c --- /dev/null +++ b/vllm_omni/platforms/npu/models/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project diff --git a/vllm_omni/platforms/npu/models/hunyuan_fused_moe.py b/vllm_omni/platforms/npu/models/hunyuan_fused_moe.py new file mode 100644 index 000000000..46c76a652 --- /dev/null +++ b/vllm_omni/platforms/npu/models/hunyuan_fused_moe.py @@ -0,0 +1,108 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +from typing import Any + +import torch +import vllm.forward_context as _vllm_fc +from vllm.config import VllmConfig +from vllm.distributed import get_ep_group +from vllm.distributed.parallel_state import get_tensor_model_parallel_world_size +from vllm.distributed.parallel_state import ( + init_model_parallel_group as vllm_init_model_parallel_group, +) +from vllm_ascend.ascend_forward_context import MoECommType +from vllm_ascend.ops.fused_moe.fused_moe import AscendSharedFusedMoE +from vllm_ascend.ops.fused_moe.moe_comm_method import _MoECommMethods +from vllm_ascend.utils import AscendDeviceType, get_ascend_device_type + +from vllm_omni.diffusion.distributed.parallel_state import ( + get_data_parallel_world_size, + get_world_group, +) +from vllm_omni.diffusion.forward_context import get_forward_context as omni_get_ctx + + +def _init_mc2_group_for_diffusion( + world_size: int, + data_parallel_size: int, + tensor_parallel_size: int, + backend: str, + local_rank: int, +) -> None: + import vllm_ascend.distributed.parallel_state as vllm_ascend_parallel_state + + if getattr(vllm_ascend_parallel_state, "_MC2", None) is not None: + return + all_ranks = torch.arange(world_size).reshape(-1, data_parallel_size * tensor_parallel_size) + group_ranks = all_ranks.unbind(0) + group_ranks = [x.tolist() for x in group_ranks] + + vllm_ascend_parallel_state._MC2 = vllm_init_model_parallel_group( + group_ranks, + local_rank, + backend, + group_name="mc2", + ) + + +def _select_moe_comm_method(vllm_config: VllmConfig) -> MoECommType | None: + soc_version = get_ascend_device_type() + if not vllm_config.parallel_config.enable_expert_parallel or get_ep_group().world_size == 1: + moe_comm_type = MoECommType.ALLGATHER + elif soc_version in {AscendDeviceType.A2}: + moe_comm_type = MoECommType.ALLGATHER + elif soc_version in {AscendDeviceType.A3}: + moe_comm_type = MoECommType.ALLTOALL + elif soc_version in {AscendDeviceType._310P}: + moe_comm_type = MoECommType.ALLGATHER + elif soc_version in {AscendDeviceType.A5}: + moe_comm_type = MoECommType.ALLTOALL + else: + raise ValueError(f"Unsupported soc_version: {soc_version}") + return moe_comm_type + + +def prepare_hunyuan_fused_moe_runtime() -> None: + world_size = torch.distributed.get_world_size() + data_parallel_size = get_data_parallel_world_size() + tensor_parallel_size = get_tensor_model_parallel_world_size() + backend = torch.distributed.get_backend(get_world_group().device_group) + local_rank = get_world_group().local_rank + _init_mc2_group_for_diffusion( + world_size=world_size, + data_parallel_size=data_parallel_size, + tensor_parallel_size=tensor_parallel_size, + backend=backend, + local_rank=local_rank, + ) + + if not hasattr(_vllm_fc.ForwardContext, "moe_comm_method"): + _vllm_fc.ForwardContext.__annotations__["in_profile_run"] = bool + _vllm_fc.ForwardContext.in_profile_run = False + + _vllm_fc.ForwardContext.moe_comm_type = _select_moe_comm_method(vllm_config=omni_get_ctx().vllm_config) + _vllm_fc.ForwardContext.moe_comm_method = _MoECommMethods.get(_vllm_fc.ForwardContext.moe_comm_type) + _vllm_fc.ForwardContext.flash_comm_v1_enabled = False + + +class AscendHunyuanFusedMoE(AscendSharedFusedMoE): + def __init__(self, *, prefix: str = "", **kwargs: Any) -> None: + super().__init__(prefix=prefix, **kwargs) + self._prefix = prefix + self._init_hook_handle = self.register_forward_pre_hook(self._initialize_kernel_hook, with_kwargs=True) + + def _initialize_kernel_hook(self, module: Any, args: Any, kwargs: Any) -> None: + if self.quant_method: + self.quant_method.process_weights_after_loading(self) + self._init_hook_handle.remove() + + def forward(self, hidden_states: Any, router_logits: Any) -> Any: + return super().forward(hidden_states, router_logits) + + def __del__(self): + import vllm_ascend.distributed.parallel_state as vllm_ascend_parallel_state + + if vllm_ascend_parallel_state._MC2: + vllm_ascend_parallel_state._MC2.destroy() + vllm_ascend_parallel_state._MC2 = None diff --git a/vllm_omni/platforms/npu/platform.py b/vllm_omni/platforms/npu/platform.py index 3c2495c3d..bda4e4f61 100644 --- a/vllm_omni/platforms/npu/platform.py +++ b/vllm_omni/platforms/npu/platform.py @@ -1,6 +1,8 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +from typing import Any + import torch from vllm.logger import init_logger from vllm_ascend.platform import NPUPlatform @@ -33,6 +35,23 @@ class NPUOmniPlatform(OmniPlatform, NPUPlatform): def get_default_stage_config_path(cls) -> str: return "vllm_omni/platforms/npu/stage_configs" + @classmethod + def get_diffusion_model_impl_qualname(cls, op_name: str) -> str: + if op_name == "hunyuan_fused_moe": + return "vllm_omni.platforms.npu.models.hunyuan_fused_moe.AscendHunyuanFusedMoE" + return super().get_diffusion_model_impl_qualname(op_name) + + @classmethod + def prepare_diffusion_op_runtime(cls, op_name: str, **kwargs: Any) -> None: + if op_name != "hunyuan_fused_moe": + return + + from vllm_omni.platforms.npu.models.hunyuan_fused_moe import ( + prepare_hunyuan_fused_moe_runtime, + ) + + prepare_hunyuan_fused_moe_runtime() + @classmethod def get_diffusion_attn_backend_cls( cls, From c8c60e19709e3be7326e0bcd9124f935517dd598 Mon Sep 17 00:00:00 2001 From: SYLAR <125541396+lishunyang12@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:09:34 +0800 Subject: [PATCH 29/38] [Config Refactor][1/2] Model Pipeline Configuration System (#1115) Signed-off-by: lishunyang --- tests/test_config_factory.py | 570 +++++++++++++++++++++++++++ vllm_omni/config/__init__.py | 20 + vllm_omni/config/stage_config.py | 487 +++++++++++++++++++++++ vllm_omni/config/yaml_util.py | 65 +++ vllm_omni/entrypoints/omni.py | 60 ++- vllm_omni/entrypoints/omni_stage.py | 5 +- vllm_omni/entrypoints/stage_utils.py | 4 +- vllm_omni/entrypoints/utils.py | 23 +- 8 files changed, 1188 insertions(+), 46 deletions(-) create mode 100644 tests/test_config_factory.py create mode 100644 vllm_omni/config/stage_config.py create mode 100644 vllm_omni/config/yaml_util.py diff --git a/tests/test_config_factory.py b/tests/test_config_factory.py new file mode 100644 index 000000000..21ef22523 --- /dev/null +++ b/tests/test_config_factory.py @@ -0,0 +1,570 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +""" +Unit tests for StageConfigFactory and related classes. +""" + +from vllm_omni.config.stage_config import ( + ModelPipeline, + StageConfig, + StageConfigFactory, + StageType, +) + + +class TestStageType: + """Tests for StageType enum.""" + + def test_stage_type_values(self): + """Test StageType enum values.""" + assert StageType.LLM.value == "llm" + assert StageType.DIFFUSION.value == "diffusion" + + def test_stage_type_from_string(self): + """Test creating StageType from string.""" + assert StageType("llm") == StageType.LLM + assert StageType("diffusion") == StageType.DIFFUSION + + +class TestStageConfig: + """Tests for StageConfig dataclass.""" + + def test_minimal_config(self): + """Test creating StageConfig with minimal required fields.""" + config = StageConfig(stage_id=0, model_stage="thinker") + assert config.stage_id == 0 + assert config.model_stage == "thinker" + assert config.stage_type == StageType.LLM + assert config.input_sources == [] + assert config.final_output is False + assert config.worker_type is None + + def test_full_config(self): + """Test creating StageConfig with all fields.""" + config = StageConfig( + stage_id=1, + model_stage="talker", + stage_type=StageType.LLM, + input_sources=[0], + custom_process_input_func="module.path.func", + final_output=True, + final_output_type="audio", + worker_type="ar", + scheduler_cls="path.to.Scheduler", + hf_config_name="talker_config", + is_comprehension=False, + ) + assert config.stage_id == 1 + assert config.model_stage == "talker" + assert config.input_sources == [0] + assert config.final_output_type == "audio" + assert config.worker_type == "ar" + + def test_to_omegaconf_basic(self): + """Test converting StageConfig to OmegaConf format.""" + config = StageConfig( + stage_id=0, + model_stage="thinker", + stage_type=StageType.LLM, + worker_type="ar", + final_output=True, + final_output_type="text", + ) + omega_config = config.to_omegaconf() + + assert omega_config.stage_id == 0 + assert omega_config.stage_type == "llm" + assert omega_config.engine_args.model_stage == "thinker" + assert omega_config.engine_args.worker_type == "ar" + assert omega_config.final_output is True + assert omega_config.final_output_type == "text" + # Legacy field name for backward compatibility + assert omega_config.engine_input_source == [] + + def test_to_omegaconf_with_runtime_overrides(self): + """Test that runtime overrides are applied to OmegaConf output.""" + config = StageConfig( + stage_id=0, + model_stage="thinker", + runtime_overrides={ + "gpu_memory_utilization": 0.9, + "tensor_parallel_size": 2, + "devices": "0,1", + "max_batch_size": 64, + }, + ) + omega_config = config.to_omegaconf() + + assert omega_config.engine_args.gpu_memory_utilization == 0.9 + assert omega_config.engine_args.tensor_parallel_size == 2 + assert omega_config.runtime.devices == "0,1" + assert omega_config.runtime.max_batch_size == 64 + + +class TestModelPipeline: + """Tests for ModelPipeline class.""" + + def test_valid_linear_dag(self): + """Test validation of a valid linear DAG.""" + stages = [ + StageConfig(stage_id=0, model_stage="thinker", input_sources=[]), + StageConfig(stage_id=1, model_stage="talker", input_sources=[0]), + StageConfig(stage_id=2, model_stage="code2wav", input_sources=[1]), + ] + pipeline = ModelPipeline(model_type="test", stages=stages) + errors = pipeline.validate_pipeline() + assert errors == [], f"Unexpected errors: {errors}" + + def test_valid_branching_dag(self): + """Test validation of a valid branching DAG.""" + stages = [ + StageConfig(stage_id=0, model_stage="input", input_sources=[]), + StageConfig(stage_id=1, model_stage="branch_a", input_sources=[0]), + StageConfig(stage_id=2, model_stage="branch_b", input_sources=[0]), + ] + pipeline = ModelPipeline(model_type="test", stages=stages) + errors = pipeline.validate_pipeline() + assert errors == [], f"Unexpected errors: {errors}" + + def test_missing_entry_point(self): + """Test that missing entry point is detected.""" + stages = [ + StageConfig(stage_id=0, model_stage="stage_a", input_sources=[1]), + StageConfig(stage_id=1, model_stage="stage_b", input_sources=[0]), + ] + pipeline = ModelPipeline(model_type="test", stages=stages) + errors = pipeline.validate_pipeline() + assert any("entry point" in e.lower() for e in errors) + + def test_missing_dependency(self): + """Test that missing stage reference is detected.""" + stages = [ + StageConfig(stage_id=0, model_stage="input", input_sources=[]), + StageConfig(stage_id=1, model_stage="output", input_sources=[99]), # Invalid + ] + pipeline = ModelPipeline(model_type="test", stages=stages) + errors = pipeline.validate_pipeline() + assert any("non-existent" in e.lower() for e in errors) + + def test_duplicate_stage_ids(self): + """Test that duplicate stage IDs are detected.""" + stages = [ + StageConfig(stage_id=0, model_stage="stage_a", input_sources=[]), + StageConfig(stage_id=0, model_stage="stage_b", input_sources=[]), # Duplicate + ] + pipeline = ModelPipeline(model_type="test", stages=stages) + errors = pipeline.validate_pipeline() + assert any("duplicate" in e.lower() for e in errors) + + def test_self_reference(self): + """Test that self-references are detected.""" + stages = [ + StageConfig(stage_id=0, model_stage="entry", input_sources=[]), + StageConfig(stage_id=1, model_stage="self_ref", input_sources=[1]), # Self + ] + pipeline = ModelPipeline(model_type="test", stages=stages) + errors = pipeline.validate_pipeline() + assert any("itself" in e.lower() for e in errors) + + def test_get_stage_by_id(self): + """Test getting stage by ID.""" + stages = [ + StageConfig(stage_id=0, model_stage="thinker", input_sources=[]), + StageConfig(stage_id=1, model_stage="talker", input_sources=[0]), + ] + pipeline = ModelPipeline(model_type="test", stages=stages) + + stage = pipeline.get_stage(1) + assert stage is not None + assert stage.model_stage == "talker" + + missing = pipeline.get_stage(99) + assert missing is None + + def test_empty_pipeline(self): + """Test validation of empty pipeline.""" + pipeline = ModelPipeline(model_type="test", stages=[]) + errors = pipeline.validate_pipeline() + assert any("no stages" in e.lower() for e in errors) + + +class TestStageConfigFactory: + """Tests for StageConfigFactory class.""" + + def test_default_diffusion_no_yaml(self): + """Test single-stage diffusion works without YAML config (@ZJY0516).""" + kwargs = { + "cache_backend": "none", + "cache_config": None, + "dtype": "bfloat16", + } + configs = StageConfigFactory.create_default_diffusion(kwargs) + + assert len(configs) == 1 + cfg = configs[0] + assert cfg["stage_id"] == 0 + assert cfg["stage_type"] == "diffusion" + assert cfg["final_output"] is True + assert cfg["final_output_type"] == "image" + + def test_default_diffusion_with_parallel_config(self): + """Test diffusion config calculates devices from parallel_config.""" + + class MockParallelConfig: + world_size = 4 + + kwargs = { + "parallel_config": MockParallelConfig(), + "cache_backend": "tea_cache", + } + configs = StageConfigFactory.create_default_diffusion(kwargs) + + assert configs[0]["runtime"]["devices"] == "0,1,2,3" + + def test_per_stage_override_precedence(self): + """Test that --stage-0-gpu-memory-utilization overrides global.""" + stage = StageConfig(stage_id=0, model_stage="thinker", input_sources=[]) + cli_overrides = { + "gpu_memory_utilization": 0.5, # Global + "stage_0_gpu_memory_utilization": 0.9, # Per-stage override + } + + overrides = StageConfigFactory._merge_cli_overrides(stage, cli_overrides) + + # Per-stage should override global + assert overrides["gpu_memory_utilization"] == 0.9 + + def test_cli_override_forwards_engine_registered_args(self): + """Test that any engine-registered CLI arg is forwarded (@wuhang2014).""" + stage = StageConfig(stage_id=0, model_stage="thinker", input_sources=[]) + cli_overrides = { + "gpu_memory_utilization": 0.9, # Well-known param + "custom_engine_flag": True, # Not in _INTERNAL_KEYS, so forwarded + } + + overrides = StageConfigFactory._merge_cli_overrides(stage, cli_overrides) + + assert overrides["gpu_memory_utilization"] == 0.9 + assert overrides["custom_engine_flag"] is True + + def test_cli_override_excludes_internal_keys(self): + """Test that internal/orchestrator keys are not forwarded.""" + stage = StageConfig(stage_id=0, model_stage="thinker", input_sources=[]) + cli_overrides = { + "gpu_memory_utilization": 0.9, + "model": "some_model", # Internal + "stage_configs_path": "/path", # Internal + "batch_timeout": 10, # Internal + } + + overrides = StageConfigFactory._merge_cli_overrides(stage, cli_overrides) + + assert overrides["gpu_memory_utilization"] == 0.9 + assert "model" not in overrides + assert "stage_configs_path" not in overrides + assert "batch_timeout" not in overrides + + def test_per_stage_override_excludes_internal_keys(self): + """Test that per-stage overrides also skip internal keys.""" + stage = StageConfig(stage_id=0, model_stage="thinker", input_sources=[]) + cli_overrides = { + "stage_0_gpu_memory_utilization": 0.9, + "stage_0_model": "override_model", # Internal, should be skipped + "stage_0_batch_timeout": 5, # Internal, should be skipped + } + + overrides = StageConfigFactory._merge_cli_overrides(stage, cli_overrides) + + assert overrides["gpu_memory_utilization"] == 0.9 + assert "model" not in overrides + assert "batch_timeout" not in overrides + + +class TestPipelineYamlParsing: + """Tests for pipeline YAML file parsing (@ZJY0516).""" + + def test_parse_qwen3_omni_moe_yaml(self, tmp_path): + """Test parsing the qwen3_omni_moe pipeline YAML.""" + yaml_content = """\ +model_type: qwen3_omni_moe +async_chunk: false + +stages: + - stage_id: 0 + model_stage: thinker + stage_type: llm + input_sources: [] + worker_type: ar + scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler + hf_config_name: thinker_config + final_output: true + final_output_type: text + is_comprehension: true + + - stage_id: 1 + model_stage: talker + stage_type: llm + input_sources: [0] + worker_type: ar + scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler + hf_config_name: talker_config + custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker + + - stage_id: 2 + model_stage: code2wav + stage_type: llm + input_sources: [1] + worker_type: generation + scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler + hf_config_name: thinker_config + custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav + final_output: true + final_output_type: audio +""" + yaml_file = tmp_path / "qwen3_omni_moe.yaml" + yaml_file.write_text(yaml_content) + + pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "qwen3_omni_moe") + + assert pipeline.model_type == "qwen3_omni_moe" + assert len(pipeline.stages) == 3 + + # Stage 0: thinker + s0 = pipeline.stages[0] + assert s0.stage_id == 0 + assert s0.model_stage == "thinker" + assert s0.stage_type == StageType.LLM + assert s0.input_sources == [] + assert s0.worker_type == "ar" + assert s0.final_output is True + assert s0.final_output_type == "text" + assert s0.is_comprehension is True + + # Stage 1: talker + s1 = pipeline.stages[1] + assert s1.stage_id == 1 + assert s1.input_sources == [0] + assert s1.custom_process_input_func == ( + "vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker" + ) + assert s1.final_output is False + + # Stage 2: code2wav + s2 = pipeline.stages[2] + assert s2.stage_id == 2 + assert s2.input_sources == [1] + assert s2.worker_type == "generation" + assert s2.final_output_type == "audio" + + def test_parse_yaml_with_legacy_engine_input_source(self, tmp_path): + """Test backward compatibility with engine_input_source field.""" + yaml_content = """\ +model_type: legacy_model + +stages: + - stage_id: 0 + model_stage: entry + stage_type: llm + - stage_id: 1 + model_stage: downstream + stage_type: llm + engine_input_source: [0] +""" + yaml_file = tmp_path / "legacy.yaml" + yaml_file.write_text(yaml_content) + + pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "legacy_model") + assert pipeline.stages[1].input_sources == [0] + + def test_parse_yaml_with_connectors_and_edges(self, tmp_path): + """Test parsing pipeline with optional connectors and edges.""" + yaml_content = """\ +model_type: test_model + +stages: + - stage_id: 0 + model_stage: entry + stage_type: llm + input_sources: [] + +connectors: + type: ray + +edges: + - from: 0 + to: 1 +""" + yaml_file = tmp_path / "with_connectors.yaml" + yaml_file.write_text(yaml_content) + + pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "test_model") + assert pipeline.connectors == {"type": "ray"} + assert pipeline.edges == [{"from": 0, "to": 1}] + + def test_parsed_pipeline_passes_validation(self, tmp_path): + """Test that a well-formed YAML produces a valid pipeline.""" + yaml_content = """\ +model_type: valid_model + +stages: + - stage_id: 0 + model_stage: entry + stage_type: llm + input_sources: [] + final_output: true + final_output_type: text + - stage_id: 1 + model_stage: next + stage_type: llm + input_sources: [0] +""" + yaml_file = tmp_path / "valid.yaml" + yaml_file.write_text(yaml_content) + + pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "valid_model") + errors = pipeline.validate_pipeline() + assert errors == [], f"Unexpected validation errors: {errors}" + + def test_parse_diffusion_stage_type(self, tmp_path): + """Test parsing a diffusion stage type from YAML.""" + yaml_content = """\ +model_type: diff_model + +stages: + - stage_id: 0 + model_stage: dit + stage_type: diffusion + input_sources: [] + final_output: true + final_output_type: image +""" + yaml_file = tmp_path / "diffusion.yaml" + yaml_file.write_text(yaml_content) + + pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "diff_model") + assert pipeline.stages[0].stage_type == StageType.DIFFUSION + + def test_parse_mimo_audio_yaml(self, tmp_path): + """Test parsing the MiMo Audio pipeline YAML.""" + yaml_content = """\ +model_type: mimo_audio + +stages: + - stage_id: 0 + model_stage: fused_thinker_talker + stage_type: llm + input_sources: [] + worker_type: ar + scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler + is_comprehension: true + final_output: true + final_output_type: text + + - stage_id: 1 + model_stage: code2wav + stage_type: llm + input_sources: [0] + worker_type: generation + scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler + custom_process_input_func: vllm_omni.model_executor.stage_input_processors.mimo_audio.llm2code2wav + final_output: true + final_output_type: audio +""" + yaml_file = tmp_path / "mimo_audio.yaml" + yaml_file.write_text(yaml_content) + + pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "mimo_audio") + + assert pipeline.model_type == "mimo_audio" + assert len(pipeline.stages) == 2 + + # Stage 0: fused_thinker_talker + s0 = pipeline.stages[0] + assert s0.stage_id == 0 + assert s0.model_stage == "fused_thinker_talker" + assert s0.input_sources == [] + assert s0.worker_type == "ar" + assert s0.is_comprehension is True + assert s0.final_output is True + assert s0.final_output_type == "text" + + # Stage 1: code2wav + s1 = pipeline.stages[1] + assert s1.stage_id == 1 + assert s1.input_sources == [0] + assert s1.worker_type == "generation" + assert s1.custom_process_input_func == ( + "vllm_omni.model_executor.stage_input_processors.mimo_audio.llm2code2wav" + ) + assert s1.final_output is True + assert s1.final_output_type == "audio" + + # Pipeline validation + errors = pipeline.validate_pipeline() + assert errors == [], f"Unexpected errors: {errors}" + + +class TestAsyncChunk: + """Tests for async_chunk pipeline flag.""" + + def test_model_pipeline_async_chunk_default(self): + """Test ModelPipeline defaults async_chunk to False.""" + stages = [StageConfig(stage_id=0, model_stage="entry", input_sources=[])] + pipeline = ModelPipeline(model_type="test", stages=stages) + assert pipeline.async_chunk is False + + def test_model_pipeline_async_chunk_set(self): + """Test ModelPipeline with async_chunk=True.""" + stages = [StageConfig(stage_id=0, model_stage="entry", input_sources=[])] + pipeline = ModelPipeline(model_type="test", stages=stages, async_chunk=True) + assert pipeline.async_chunk is True + + def test_parse_async_chunk_from_yaml(self, tmp_path): + """Test that async_chunk is parsed from pipeline YAML.""" + yaml_content = """\ +model_type: test_async +async_chunk: true + +stages: + - stage_id: 0 + model_stage: entry + stage_type: llm + input_sources: [] +""" + yaml_file = tmp_path / "async.yaml" + yaml_file.write_text(yaml_content) + + pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "test_async") + assert pipeline.async_chunk is True + + def test_parse_missing_async_chunk_defaults_false(self, tmp_path): + """Test that missing async_chunk defaults to False.""" + yaml_content = """\ +model_type: test_no_async + +stages: + - stage_id: 0 + model_stage: entry + stage_type: llm + input_sources: [] +""" + yaml_file = tmp_path / "no_async.yaml" + yaml_file.write_text(yaml_content) + + pipeline = StageConfigFactory._parse_pipeline_yaml(yaml_file, "test_no_async") + assert pipeline.async_chunk is False + + +class TestArchitectureFallback: + """Tests for architecture-based model detection fallback.""" + + def test_architecture_models_mapping_exists(self): + """Test that _ARCHITECTURE_MODELS contains expected entries.""" + assert "MiMoAudioForConditionalGeneration" in StageConfigFactory._ARCHITECTURE_MODELS + assert StageConfigFactory._ARCHITECTURE_MODELS["MiMoAudioForConditionalGeneration"] == "mimo_audio" + assert "HunyuanImage3ForCausalMM" in StageConfigFactory._ARCHITECTURE_MODELS + assert StageConfigFactory._ARCHITECTURE_MODELS["HunyuanImage3ForCausalMM"] == "hunyuan_image3" + + def test_mimo_audio_in_pipeline_models(self): + """Test that mimo_audio is registered in PIPELINE_MODELS.""" + assert "mimo_audio" in StageConfigFactory.PIPELINE_MODELS diff --git a/vllm_omni/config/__init__.py b/vllm_omni/config/__init__.py index e2db6f427..2aa236e69 100644 --- a/vllm_omni/config/__init__.py +++ b/vllm_omni/config/__init__.py @@ -4,8 +4,28 @@ Configuration module for vLLM-Omni. from vllm_omni.config.lora import LoRAConfig from vllm_omni.config.model import OmniModelConfig +from vllm_omni.config.stage_config import ( + ModelPipeline, + StageConfig, + StageConfigFactory, + StageType, +) +from vllm_omni.config.yaml_util import ( + create_config, + load_yaml_config, + merge_configs, + to_dict, +) __all__ = [ "OmniModelConfig", "LoRAConfig", + "StageConfig", + "StageConfigFactory", + "ModelPipeline", + "StageType", + "create_config", + "load_yaml_config", + "merge_configs", + "to_dict", ] diff --git a/vllm_omni/config/stage_config.py b/vllm_omni/config/stage_config.py new file mode 100644 index 000000000..ad298aa9d --- /dev/null +++ b/vllm_omni/config/stage_config.py @@ -0,0 +1,487 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +""" +Stage Configuration System for vLLM-Omni. + +Pipeline structure (stages, types, data-flow) is defined in per-model YAML +files and is set by model developers at integration time. +Runtime parameters (gpu_memory_utilization, tp_size, etc.) come from CLI. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Any + +from vllm.logger import init_logger + +from vllm_omni.config.yaml_util import create_config, load_yaml_config, to_dict + +# Pipeline YAMLs live alongside model code in model_executor/models// +_MODELS_DIR = Path(__file__).resolve().parent.parent / "model_executor" / "models" + + +def get_pipeline_path(model_dir: str, filename: str) -> Path: + """Return the full path to a pipeline YAML file. + + Args: + model_dir: Model subdirectory name (e.g., "qwen3_omni"). + filename: Name of the YAML file (e.g., "pipeline.yaml"). + + Returns: + Absolute path to the file. + """ + return _MODELS_DIR / model_dir / filename + + +logger = init_logger(__name__) + + +class StageType(str, Enum): + """Type of processing stage in the Omni pipeline.""" + + LLM = "llm" + DIFFUSION = "diffusion" + + +@dataclass +class StageConfig: + """Per-stage configuration — pipeline-structure fields only. + + Engine params (gpu_memory_utilization, tp_size, etc.) come from CLI, + NOT from this class. + """ + + # Identity + stage_id: int + model_stage: str + + # Stage type + stage_type: StageType = StageType.LLM + + input_sources: list[int] = field(default_factory=list) + custom_process_input_func: str | None = None + final_output: bool = False + final_output_type: str | None = None # "text", "audio", "image" + worker_type: str | None = None # "ar" or "generation" + scheduler_cls: str | None = None + hf_config_name: str | None = None + is_comprehension: bool = False + + # Runtime overrides (populated from CLI, not from pipeline YAML) + runtime_overrides: dict[str, Any] = field(default_factory=dict) + + def to_omegaconf(self) -> Any: + """Convert to OmegaConf for backward compatibility with OmniStage. + + Returns: + OmegaConf DictConfig with stage configuration in legacy format. + """ + # Build engine_args dict with required fields + engine_args: dict[str, Any] = { + "model_stage": self.model_stage, + } + + if self.worker_type: + engine_args["worker_type"] = self.worker_type + if self.scheduler_cls: + engine_args["scheduler_cls"] = self.scheduler_cls + if self.hf_config_name: + engine_args["hf_config_name"] = self.hf_config_name + + # Apply runtime overrides (CLI args) + for key, value in self.runtime_overrides.items(): + if key not in ("devices", "max_batch_size"): + engine_args[key] = value + + # Build runtime config + runtime: dict[str, Any] = { + "process": True, + "max_batch_size": self.runtime_overrides.get("max_batch_size", 1), + } + if "devices" in self.runtime_overrides: + runtime["devices"] = self.runtime_overrides["devices"] + + # Build full config dict + config_dict: dict[str, Any] = { + "stage_id": self.stage_id, + "stage_type": StageType(self.stage_type).value, + "engine_args": create_config(engine_args), + "runtime": create_config(runtime), + "engine_input_source": self.input_sources, # Legacy field name + "final_output": self.final_output, + "final_output_type": self.final_output_type, + "is_comprehension": self.is_comprehension, + } + + if self.custom_process_input_func: + config_dict["custom_process_input_func"] = self.custom_process_input_func + + return create_config(config_dict) + + +@dataclass +class ModelPipeline: + """Complete pipeline definition for a multi-stage model. + + Defined by model developers, bundled with the model, not user-editable. + """ + + model_type: str + stages: list[StageConfig] + + # Pipeline-wide behavior flags + async_chunk: bool = False + + # Optional distributed configuration + connectors: dict[str, Any] | None = None + edges: list[dict[str, Any]] | None = None + + def get_stage(self, stage_id: int) -> StageConfig | None: + """Look up a stage by its ID. + + Args: + stage_id: The stage ID to search for. + + Returns: + The matching StageConfig, or None if not found. + """ + for stage in self.stages: + if stage.stage_id == stage_id: + return stage + return None + + def validate_pipeline(self) -> list[str]: + """Validate pipeline topology at model integration time (not runtime). + + Checks: + - All stage IDs are unique + - All input_sources reference valid stage IDs + - At least one entry point (stage with empty input_sources) + + Returns: + List of validation error messages. Empty list if valid. + """ + errors: list[str] = [] + + if not self.stages: + errors.append("Topology has no stages defined") + return errors + + # Check for unique stage IDs + stage_ids = [s.stage_id for s in self.stages] + if len(stage_ids) != len(set(stage_ids)): + errors.append("Duplicate stage IDs found") + + stage_id_set = set(stage_ids) + + # Check input_sources reference valid stages + for stage in self.stages: + for source_id in stage.input_sources: + if source_id not in stage_id_set: + errors.append(f"Stage {stage.stage_id} references non-existent input source {source_id}") + if source_id == stage.stage_id: + errors.append(f"Stage {stage.stage_id} references itself as input source") + + # Check for at least one entry point + entry_points = [s for s in self.stages if not s.input_sources] + if not entry_points: + errors.append("No entry point found (stage with empty input_sources)") + + return errors + + +class StageConfigFactory: + """Factory that loads pipeline YAML and merges CLI overrides. + + Handles both single-stage and multi-stage models. + """ + + # Mapping of model types to directories under model_executor/models/. + PIPELINE_MODELS: dict[str, str] = { + "qwen3_omni_moe": "qwen3_omni", + "qwen2_5_omni": "qwen2_5_omni", + "bagel": "bagel", + "qwen3_tts": "qwen3_tts", + "mimo_audio": "mimo_audio", + "glm-image": "glm_image", + "cosyvoice3": "cosyvoice3", + "mammothmoda2": "mammoth_moda2", + } + + # Fallback: map HF architecture class names to pipeline dirs. + # Used when model_type collides with another model (e.g. MiMo Audio + # reports model_type="qwen2" which matches plain Qwen2, not our pipeline). + _ARCHITECTURE_MODELS: dict[str, str] = { + "MiMoAudioForConditionalGeneration": "mimo_audio", + "HunyuanImage3ForCausalMM": "hunyuan_image3", + } + + @classmethod + def create_from_model( + cls, + model: str, + cli_overrides: dict[str, Any] | None = None, + ) -> list[StageConfig] | None: + """Load pipeline YAML, merge with CLI overrides. + + Args: + model: Model name or path. + cli_overrides: CLI overrides from VllmConfig/OmniDiffusionConfig. + + Returns: + List of StageConfig objects with CLI overrides applied, + or None if no pipeline definition was found for this model. + """ + if cli_overrides is None: + cli_overrides = {} + + trust_remote_code = cli_overrides.get("trust_remote_code", True) + pipeline = cls._load_pipeline(model, trust_remote_code=trust_remote_code) + + if pipeline is None: + return None + + errors = pipeline.validate_pipeline() + if errors: + logger.warning(f"Pipeline validation warnings for {model}: {errors}") + + # Apply CLI overrides + result: list[StageConfig] = [] + for stage in pipeline.stages: + # Merge global CLI overrides + stage.runtime_overrides = cls._merge_cli_overrides(stage, cli_overrides) + result.append(stage) + + return result + + @classmethod + def create_default_diffusion(cls, kwargs: dict[str, Any]) -> list[dict[str, Any]]: + """Single-stage diffusion - no YAML needed. + + Creates a default diffusion stage configuration for single-stage + diffusion models. Returns a legacy OmegaConf-compatible dict for + backward compatibility with OmniStage. + + Args: + kwargs: Engine arguments from CLI/API. + + Returns: + List containing a single config dict for the diffusion stage. + """ + # Calculate devices based on parallel config + devices = "0" + if "parallel_config" in kwargs: + num_devices = kwargs["parallel_config"].world_size + for i in range(1, num_devices): + devices += f",{i}" + + # Collect engine args – skip non-serializable objects + engine_args: dict[str, Any] = {} + for key, value in kwargs.items(): + if key in ("parallel_config",): + continue + engine_args[key] = value + + engine_args.setdefault("cache_backend", "none") + engine_args["model_stage"] = "diffusion" + + # Convert dtype to string for OmegaConf + if "dtype" in engine_args: + engine_args["dtype"] = str(engine_args["dtype"]) + + config_dict: dict[str, Any] = { + "stage_id": 0, + "stage_type": StageType.DIFFUSION.value, + "runtime": { + "process": True, + "devices": devices, + "max_batch_size": 1, + }, + "engine_args": create_config(engine_args), + "final_output": True, + "final_output_type": "image", + } + + return [config_dict] + + @classmethod + def _load_pipeline(cls, model: str, trust_remote_code: bool = True) -> ModelPipeline | None: + """Load pipeline YAML for the model. + + Args: + model: Model name or path. + trust_remote_code: Whether to trust remote code for HF config loading. + + Returns: + ModelPipeline if found, None otherwise. + """ + model_type, hf_config = cls._auto_detect_model_type(model, trust_remote_code=trust_remote_code) + if model_type is None: + return None + + pipeline_dir = cls.PIPELINE_MODELS.get(model_type) + + # Fallback: check HF architectures when model_type doesn't match + if pipeline_dir is None and hf_config is not None: + for arch in getattr(hf_config, "architectures", []) or []: + pipeline_dir = cls._ARCHITECTURE_MODELS.get(arch) + if pipeline_dir is not None: + model_type = pipeline_dir + break + + if pipeline_dir is None: + logger.debug(f"No pipeline mapping for model_type: {model_type}") + return None + + pipeline_path = get_pipeline_path(pipeline_dir, "pipeline.yaml") + + if not pipeline_path.exists(): + logger.debug(f"Pipeline file not found: {pipeline_path}") + return None + + return cls._parse_pipeline_yaml(pipeline_path, model_type) + + @classmethod + def _parse_pipeline_yaml(cls, path: Path, model_type: str) -> ModelPipeline: + """Parse a pipeline YAML file. + + Args: + path: Path to the YAML file. + model_type: Model type identifier. + + Returns: + ModelPipeline object. + """ + config_data = load_yaml_config(path) + + stages: list[StageConfig] = [] + for stage_data in config_data.stages: + # Use .get() for optional fields — idiomatic for OmegaConf DictConfig + stage_type_str = stage_data.get("stage_type", "llm") + stage_type = StageType(stage_type_str) if stage_type_str else StageType.LLM + + # Handle both 'input_sources' (new) and 'engine_input_source' (legacy) + input_sources = stage_data.get("input_sources", None) + if input_sources is None: + input_sources = stage_data.get("engine_input_source", []) + if input_sources is None: + input_sources = [] + input_sources = list(input_sources) + + stage = StageConfig( + stage_id=stage_data.stage_id, + model_stage=stage_data.model_stage, + stage_type=stage_type, + input_sources=input_sources, + custom_process_input_func=stage_data.get("custom_process_input_func", None), + final_output=stage_data.get("final_output", False), + final_output_type=stage_data.get("final_output_type", None), + worker_type=stage_data.get("worker_type", None), + scheduler_cls=stage_data.get("scheduler_cls", None), + hf_config_name=stage_data.get("hf_config_name", None), + is_comprehension=stage_data.get("is_comprehension", False), + ) + stages.append(stage) + + # Get pipeline-wide flags + async_chunk = config_data.get("async_chunk", False) + + # Get optional connector config + connectors = to_dict(config_data.connectors) if hasattr(config_data, "connectors") else None + edges = to_dict(config_data.edges) if hasattr(config_data, "edges") else None + + return ModelPipeline( + model_type=getattr(config_data, "model_type", model_type), + stages=stages, + async_chunk=async_chunk, + connectors=connectors, + edges=edges, + ) + + @classmethod + def _auto_detect_model_type(cls, model: str, trust_remote_code: bool = True) -> tuple[str | None, Any]: + """Auto-detect model_type from model directory. + + Args: + model: Model name or path. + trust_remote_code: Whether to trust remote code for HF config loading. + + Returns: + Tuple of (model_type, hf_config). Both may be None on failure. + """ + try: + from vllm.transformers_utils.config import get_config + + hf_config = get_config(model, trust_remote_code=trust_remote_code) + return hf_config.model_type, hf_config + except Exception as e: + logger.debug(f"Failed to auto-detect model type for {model}: {e}") + return None, None + + # Keys that should never be forwarded as engine overrides (internal / + # orchestrator-only knobs, complex objects, etc.). + _INTERNAL_KEYS: set[str] = { + "model", + "stage_configs_path", + "stage_id", + "stage_init_timeout", + "init_timeout", + "shm_threshold_bytes", + "worker_backend", + "ray_address", + "batch_timeout", + "log_stats", + "tokenizer", + "parallel_config", + } + + @classmethod + def _merge_cli_overrides( + cls, + stage: StageConfig, + cli_overrides: dict[str, Any], + ) -> dict[str, Any]: + """Merge CLI overrides into stage runtime config. + + All CLI arguments registered by engine config classes (e.g. + EngineArgs / OmniDiffusionConfig) are accepted as overrides + unless they appear in ``_INTERNAL_KEYS``. + + Handles: + - Global overrides (apply to all stages) + - Per-stage overrides (--stage-N-* format, take precedence) + + Args: + stage: The stage to merge overrides into. + cli_overrides: CLI arguments from VllmConfig/OmniDiffusionConfig. + + Returns: + Dict of runtime overrides for this stage. + """ + result: dict[str, Any] = {} + + # Apply global overrides – any key not in the internal blocklist + # is forwarded so that engine-registered params work out of the box. + for key, value in cli_overrides.items(): + if key in cls._INTERNAL_KEYS: + continue + if re.match(r"stage_\d+_", key): + # Per-stage keys handled below + continue + if value is not None: + result[key] = value + + # Apply per-stage overrides (--stage-N-* format, take precedence) + stage_prefix = f"stage_{stage.stage_id}_" + for key, value in cli_overrides.items(): + if key.startswith(stage_prefix) and value is not None: + param_name = key[len(stage_prefix) :] + if param_name in cls._INTERNAL_KEYS: + continue + result[param_name] = value + + return result diff --git a/vllm_omni/config/yaml_util.py b/vllm_omni/config/yaml_util.py new file mode 100644 index 000000000..09b27ca02 --- /dev/null +++ b/vllm_omni/config/yaml_util.py @@ -0,0 +1,65 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +""" +Centralized OmegaConf wrapper for vLLM-Omni. + +All OmegaConf usage in the project MUST go through this module. +Other modules should import these wrapper functions instead of +using OmegaConf directly. +""" + +from __future__ import annotations + +from typing import Any + +from omegaconf import DictConfig, OmegaConf + + +def load_yaml_config(path: str | Any) -> DictConfig: + """Load a YAML file and return it as a DictConfig. + + Args: + path: Path to the YAML file. + + Returns: + OmegaConf DictConfig with attribute-style access. + """ + return OmegaConf.load(path) # type: ignore[return-value] + + +def create_config(data: Any) -> DictConfig: + """Wrap a dict (or list) into a DictConfig. + + Args: + data: Dict, list, or other structure to wrap. + + Returns: + OmegaConf DictConfig / ListConfig. + """ + return OmegaConf.create(data) + + +def merge_configs(*cfgs: Any) -> dict: + """Deep-merge multiple configs and return a plain dict. + + Args: + *cfgs: DictConfig or dict objects to merge (left to right). + + Returns: + Plain dict with merged, resolved values. + """ + merged = OmegaConf.merge(*cfgs) + return OmegaConf.to_container(merged, resolve=True) # type: ignore[return-value] + + +def to_dict(obj: Any, *, resolve: bool = True) -> Any: + """Convert a DictConfig (or similar) to a plain dict. + + Args: + obj: OmegaConf container to convert. + resolve: Whether to resolve interpolations (default True). + + Returns: + Plain dict. + """ + return OmegaConf.to_container(obj, resolve=resolve) # type: ignore[return-value] diff --git a/vllm_omni/entrypoints/omni.py b/vllm_omni/entrypoints/omni.py index c414b972b..488b986d8 100644 --- a/vllm_omni/entrypoints/omni.py +++ b/vllm_omni/entrypoints/omni.py @@ -15,13 +15,14 @@ import huggingface_hub import msgspec.msgpack import torch import zmq -from omegaconf import OmegaConf from tqdm.auto import tqdm from vllm import SamplingParams from vllm.logger import init_logger from vllm.utils.network_utils import make_zmq_socket from vllm.v1.utils import get_engine_client_zmq_addr +from vllm_omni.config.stage_config import StageConfigFactory +from vllm_omni.config.yaml_util import create_config from vllm_omni.distributed.omni_connectors import ( get_stage_connector_config, initialize_orchestrator_connectors, @@ -224,46 +225,35 @@ class OmniBase: cache_config = self._get_default_cache_config(cache_backend) return cache_config - def _create_default_diffusion_stage_cfg(self, kwargs: dict[str, Any]) -> dict[str, Any]: - """Create default diffusion stage configuration.""" - # We temporally create a default config for diffusion stage. - # In the future, we should merge the default config with the user-provided config. - # TODO: hack, convert dtype to string to avoid non-premitive omegaconf create error. + def _create_default_diffusion_stage_cfg(self, kwargs: dict[str, Any]) -> list[dict[str, Any]]: + """Create default diffusion stage configuration. + + Uses StageConfigFactory for typed configuration creation while + maintaining backward compatibility with the legacy format. + + Args: + kwargs: Engine arguments from CLI/API. + + Returns: + List containing a single OmegaConf config for the diffusion stage. + """ + # Normalize dtype if "dtype" in kwargs and not isinstance(kwargs["dtype"], str): if not isinstance(kwargs["dtype"], torch.dtype): raise TypeError(f"Provided dtype must be a string or torch.dtype, got {type(kwargs['dtype']).__name__}") kwargs["dtype"] = str(kwargs["dtype"]).removeprefix("torch.") + # Normalize cache config before passing to factory cache_backend = kwargs.get("cache_backend", "none") cache_config = self._normalize_cache_config(cache_backend, kwargs.get("cache_config", None)) - # TODO: hack, calculate devices based on parallel config. - devices = "0" - if "parallel_config" in kwargs: - num_devices = kwargs["parallel_config"].world_size - for i in range(1, num_devices): - devices += f",{i}" - default_stage_cfg = [ - { - "stage_id": 0, - "stage_type": "diffusion", - "runtime": { - "process": True, - "devices": devices, - "max_batch_size": 1, - }, - "engine_args": OmegaConf.create( - { - **kwargs, - "cache_backend": cache_backend, - "cache_config": cache_config, - } - ), - "final_output": True, - "final_output_type": "image", - } - ] - default_stage_cfg[0]["engine_args"]["model_stage"] = "diffusion" - return default_stage_cfg + + # Update kwargs with normalized values + kwargs_copy = dict(kwargs) + kwargs_copy["cache_backend"] = cache_backend + kwargs_copy["cache_config"] = cache_config + + # Use the factory to create default diffusion config + return StageConfigFactory.create_default_diffusion(kwargs_copy) def _resolve_stage_configs(self, model: str, kwargs: dict[str, Any]) -> tuple[str, list[Any]]: """Resolve stage configs and inject defaults shared by orchestrator/headless.""" @@ -290,7 +280,7 @@ class OmniBase: if getattr(cfg, "stage_type", None) != "diffusion": continue if not hasattr(cfg, "engine_args") or cfg.engine_args is None: - cfg.engine_args = OmegaConf.create({}) + cfg.engine_args = create_config({}) if kwargs.get("lora_path") is not None: if not hasattr(cfg.engine_args, "lora_path") or cfg.engine_args.lora_path is None: cfg.engine_args.lora_path = kwargs["lora_path"] diff --git a/vllm_omni/entrypoints/omni_stage.py b/vllm_omni/entrypoints/omni_stage.py index 3f5a14e97..129832267 100644 --- a/vllm_omni/entrypoints/omni_stage.py +++ b/vllm_omni/entrypoints/omni_stage.py @@ -269,7 +269,10 @@ class OmniStage: self.engine_args = stage_config.engine_args self.model_stage = stage_config.engine_args.model_stage self.requires_multimodal_data = getattr(stage_config.runtime, "requires_multimodal_data", False) - self.engine_input_source = getattr(stage_config, "engine_input_source", []) + # Support both 'input_sources' (new format) and 'engine_input_source' (legacy) + self.engine_input_source = ( + getattr(stage_config, "input_sources", None) or getattr(stage_config, "engine_input_source", []) or [] + ) self.engine_output_type = getattr(stage_config.engine_args, "engine_output_type", None) self.engine_outputs = None self.is_comprehension = getattr(stage_config, "is_comprehension", False) diff --git a/vllm_omni/entrypoints/stage_utils.py b/vllm_omni/entrypoints/stage_utils.py index de4a71719..99f2042e2 100644 --- a/vllm_omni/entrypoints/stage_utils.py +++ b/vllm_omni/entrypoints/stage_utils.py @@ -9,7 +9,7 @@ from collections.abc import Callable from multiprocessing import shared_memory as _shm from typing import Any -from omegaconf import OmegaConf +from vllm_omni.config.yaml_util import to_dict as _omega_to_dict logger = logging.getLogger(__name__) @@ -315,7 +315,7 @@ def _to_dict(x: Any) -> dict[str, Any]: try: if isinstance(x, dict): return dict(x) - return OmegaConf.to_container(x, resolve=True) # type: ignore[arg-type] + return _omega_to_dict(x) except Exception: try: return dict(x) diff --git a/vllm_omni/entrypoints/utils.py b/vllm_omni/entrypoints/utils.py index 69ce73fc4..0e31bfa7c 100644 --- a/vllm_omni/entrypoints/utils.py +++ b/vllm_omni/entrypoints/utils.py @@ -5,11 +5,11 @@ from dataclasses import asdict, fields, is_dataclass from pathlib import Path from typing import Any, get_args, get_origin -from omegaconf import OmegaConf from vllm.logger import init_logger from vllm.transformers_utils.config import get_config, get_hf_file_to_dict from vllm.transformers_utils.repo_utils import file_or_path_exists +from vllm_omni.config.yaml_util import create_config, load_yaml_config, merge_configs from vllm_omni.entrypoints.stage_utils import _to_dict from vllm_omni.platforms import current_omni_platform @@ -234,6 +234,11 @@ def resolve_model_config_path(model: str) -> str: def load_stage_configs_from_model(model: str, base_engine_args: dict | None = None) -> list: """Load stage configurations from model's default config file. + .. deprecated:: + This is the legacy OmegaConf-based loading path. New code should use + ``StageConfigFactory.create_from_model()`` instead. This function will + be removed once all callers are migrated (see PR series [2/N]). + Loads stage configurations based on the model type and device type. First tries to load a device-specific YAML file from stage_configs/{device_type}/ directory. If not found, falls back to the default config file. @@ -259,6 +264,9 @@ def load_stage_configs_from_model(model: str, base_engine_args: dict | None = No def load_stage_configs_from_yaml(config_path: str, base_engine_args: dict | None = None) -> list: """Load stage configurations from a YAML file. + .. deprecated:: + Legacy OmegaConf-based loader. Will be removed in PR series [2/N]. + Args: config_path: Path to the YAML configuration file @@ -267,17 +275,17 @@ def load_stage_configs_from_yaml(config_path: str, base_engine_args: dict | None """ if base_engine_args is None: base_engine_args = {} - config_data = OmegaConf.load(config_path) + config_data = load_yaml_config(config_path) stage_args = config_data.stage_args global_async_chunk = config_data.get("async_chunk", False) - # Convert any nested dataclass objects to dicts before creating OmegaConf + # Convert any nested dataclass objects to dicts before creating DictConfig base_engine_args = _convert_dataclasses_to_dict(base_engine_args) - base_engine_args = OmegaConf.create(base_engine_args) + base_engine_args = create_config(base_engine_args) for stage_arg in stage_args: base_engine_args_tmp = base_engine_args.copy() # Update base_engine_args with stage-specific engine_args if they exist if hasattr(stage_arg, "engine_args") and stage_arg.engine_args is not None: - base_engine_args_tmp = OmegaConf.merge(base_engine_args_tmp, stage_arg.engine_args) + base_engine_args_tmp = create_config(merge_configs(base_engine_args_tmp, stage_arg.engine_args)) stage_type = getattr(stage_arg, "stage_type", "llm") if hasattr(stage_arg, "runtime") and stage_arg.runtime is not None and stage_type != "diffusion": runtime_cfg = stage_arg.runtime @@ -312,7 +320,7 @@ def load_and_resolve_stage_configs( if not stage_configs: if default_stage_cfg_factory is not None: default_stage_cfg = default_stage_cfg_factory() - stage_configs = OmegaConf.create(default_stage_cfg) + stage_configs = create_config(default_stage_cfg) else: stage_configs = [] else: @@ -347,6 +355,7 @@ def get_final_stage_id_for_e2e( output_modalities = default_modalities try: + final_stage_id_for_e2e = last_stage_id for _sid in range(last_stage_id, -1, -1): if ( getattr(stage_list[_sid], "final_output", False) @@ -354,8 +363,6 @@ def get_final_stage_id_for_e2e( ): final_stage_id_for_e2e = _sid break - if final_stage_id_for_e2e < 0: - final_stage_id_for_e2e = last_stage_id except Exception as e: logger.debug( "[Orchestrator] Failed to determine final stage for E2E; \ From 21e621f84c295c668240041ae6e7b8f5079e5b11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zeyu=20Huang=20=7C=20=E9=BB=83=E6=BE=A4=E5=AE=87?= <11222265+fhfuih@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:32:43 +0800 Subject: [PATCH 30/38] [Test] Reduce SP & Offloading test cases for L2 (#1839) Signed-off-by: Huang, Zeyu <11222265+fhfuih@users.noreply.github.com> Co-authored-by: Hongsheng Liu --- .buildkite/test-ready.yml | 10 +--------- tests/e2e/offline_inference/test_sequence_parallel.py | 3 --- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/.buildkite/test-ready.yml b/.buildkite/test-ready.yml index 092d6f11a..66598f3ea 100644 --- a/.buildkite/test-ready.yml +++ b/.buildkite/test-ready.yml @@ -34,15 +34,7 @@ steps: - label: "Diffusion Model CPU offloading Test" depends_on: upload-ready-pipeline commands: - - | - timeout 20m bash -c ' - set +e - pytest -s -v tests/e2e/offline_inference/test_diffusion_cpu_offload.py - EXIT1=$$? - pytest -s -v tests/e2e/offline_inference/test_diffusion_layerwise_offload.py - EXIT2=$$? - exit $$((EXIT1 | EXIT2)) - ' + - timeout 10m pytest -s -v tests/e2e/offline_inference/test_diffusion_cpu_offload.py agents: queue: "gpu_1_queue" # g6.4xlarge instance on AWS, has 1 L4 GPU plugins: diff --git a/tests/e2e/offline_inference/test_sequence_parallel.py b/tests/e2e/offline_inference/test_sequence_parallel.py index d66104aa1..0f0255b44 100644 --- a/tests/e2e/offline_inference/test_sequence_parallel.py +++ b/tests/e2e/offline_inference/test_sequence_parallel.py @@ -146,9 +146,6 @@ def _run_inference( # - warmup: whether to run warmup for this SP config # - is_perf_test: whether this is a performance test (show speedup metrics) SP_CONFIGS_L2 = [ - # Ulysses-2 - performance test - (2, 1, DEFAULT_HEIGHT, DEFAULT_WIDTH, True, True), - (1, 2, DEFAULT_HEIGHT, DEFAULT_WIDTH, True, True), # Ring-2 - performance test # Hybrid - correctness only (2, 2, DEFAULT_HEIGHT, DEFAULT_WIDTH, False, False), ] From b3cced310a64239d5c9cd390834d66aed8d8f3a1 Mon Sep 17 00:00:00 2001 From: usberkeley <150880684+usberkeley@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:33:03 +0800 Subject: [PATCH 31/38] [bugfix] Add Interleaved 2D Rotary Embedding for HunyuanImage3 (#1784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Bradley Signed-off-by: princepride Signed-off-by: 汪志鹏 Co-authored-by: princepride Co-authored-by: Hongsheng Liu --- .../models/hunyuan_image3/hunyuan_image3.py | 174 +++++++++++++++--- .../stage_configs/hunyuan_image_3_moe.yaml | 4 + 2 files changed, 153 insertions(+), 25 deletions(-) diff --git a/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py b/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py index b015679c2..283f7206a 100644 --- a/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py +++ b/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py @@ -1054,6 +1054,79 @@ class HunyuanImage3MultiModalProcessor(BaseMultiModalProcessor[HunyuanImage3Proc ] +class HunyuanImage3RotaryEmbedding(nn.Module): + """Custom interleaved 2D Rotary Embedding for HunyuanImage3. + + The original HunyuanImage3 ``build_2d_rope`` interleaves y (height) and + x (width) positions across consecutive frequencies:: + + theta = inv_freq.reshape(n_elem // 4, 2) + idx_theta[2k] = y_pos * theta[k, 0] # even freq -> y + idx_theta[2k+1] = x_pos * theta[k, 1] # odd freq -> x + + vLLM's standard ``MRotaryEmbedding`` instead assigns *contiguous* blocks + of frequencies to each position dimension, producing different encodings + for the same positions. This class re-implements the original interleaved + pattern so that pre-trained weights work correctly. + + Reference: https://kexue.fm/archives/10352 + """ + + def __init__(self, head_dim: int, rope_theta: float = 10000.0) -> None: + super().__init__() + self.head_dim = head_dim + assert head_dim % 4 == 0, f"head_dim must be divisible by 4, got {head_dim}" + inv_freq = 1.0 / (rope_theta ** (torch.arange(0, head_dim, 2, dtype=torch.float32) / head_dim)) + self.register_buffer("inv_freq", inv_freq, persistent=False) + + def forward( + self, + positions: torch.Tensor, + query: torch.Tensor, + key: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor]: + if positions.dim() == 2 and positions.shape[0] == 3: + y_pos = positions[1].float() + x_pos = positions[2].float() + else: + # 1D fallback: both dims get the same position → standard RoPE + y_pos = positions.float() + x_pos = positions.float() + + num_tokens = y_pos.shape[0] + dtype = query.dtype + query_shape = query.shape + key_shape = key.shape + + inv_freq = self.inv_freq.to(device=y_pos.device, dtype=torch.float32) + + inv_freq_y = inv_freq[0::2] # even indices -> y + inv_freq_x = inv_freq[1::2] # odd indices -> x + + y_freqs = y_pos.unsqueeze(-1) * inv_freq_y.unsqueeze(0) + x_freqs = x_pos.unsqueeze(-1) * inv_freq_x.unsqueeze(0) + + # Interleave: [y*θ₀, x*θ₁, y*θ₂, x*θ₃, ...] + freqs = torch.stack([y_freqs, x_freqs], dim=-1).reshape(num_tokens, -1) + + emb = torch.cat([freqs, freqs], dim=-1) + cos = emb.cos().to(dtype).unsqueeze(1) + sin = emb.sin().to(dtype).unsqueeze(1) + + query = query.view(num_tokens, -1, self.head_dim) + key = key.view(num_tokens, -1, self.head_dim) + + query = query * cos + self._rotate_half(query) * sin + key = key * cos + self._rotate_half(key) * sin + + return query.view(query_shape), key.view(key_shape) + + def _rotate_half(self, x: torch.Tensor) -> torch.Tensor: + x1 = x[..., : x.shape[-1] // 2] + x2 = x[..., x.shape[-1] // 2 :] + return torch.cat((-x2, x1), dim=-1) + + @MULTIMODAL_REGISTRY.register_processor( HunyuanImage3MultiModalProcessor, info=HunyuanImage3ProcessingInfo, @@ -1153,6 +1226,38 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo self._mrope_joint_img_sep_token_id = tokenizer.convert_tokens_to_ids("") self._mrope_max_num_patches = config.vit_processor.get("max_num_patches", 729) + self._replace_rotary_embeddings() + + def _replace_rotary_embeddings(self): + """Replace vLLM's standard MRotaryEmbedding with the custom + interleaved 2D RoPE that matches the original HunyuanImage3 model.""" + rope_theta = getattr(self.config, "rope_theta", 10000.0) + head_dim = getattr( + self.config, + "head_dim", + getattr( + self.config, + "attention_head_dim", + self.config.hidden_size // self.config.num_attention_heads, + ), + ) + custom_rope = HunyuanImage3RotaryEmbedding( + head_dim=head_dim, + rope_theta=rope_theta, + ) + replaced = 0 + for layer in self.model.layers: + if hasattr(layer, "self_attn") and hasattr(layer.self_attn, "rotary_emb"): + layer.self_attn.rotary_emb = custom_rope + replaced += 1 + logger.info( + "Replaced %d rotary embeddings with HunyuanImage3RotaryEmbedding " + "(interleaved 2D RoPE, head_dim=%d, rope_theta=%.1f)", + replaced, + head_dim, + rope_theta, + ) + def _parse_and_validate_image_input( self, **kwargs: dict[str, Any], @@ -1415,19 +1520,20 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo """Compute mRoPE positions for HunyuanImage-3. Maps the original model's build_2d_rope logic into vLLM's 3-dim - mRoPE position tensor [3, seq_len] where dim-0 is temporal (unused, - kept equal to 1D), dim-1 is height, and dim-2 is width. + mRoPE position tensor ``[3, seq_len]`` where dim-1 is height and + dim-2 is width. dim-0 is unused (temporal) and kept equal to 1D. - For text tokens and auxiliary image tokens (timestep, ViT): + For text tokens and auxiliary image tokens (timestep, separators): All three dims get the same flat 1D position id. - For VAE image tokens: - dim-0 (T): flat 1D position id at the image start + For VAE / ViT image tokens: + dim-0 (T): flat 1D position id at the region start dim-1 (H): 2D y-position using build_2d_rope centering dim-2 (W): 2D x-position using build_2d_rope centering """ - # Extract per-image VAE grid dims from mm_features + # Extract per-image VAE and ViT grid dims from mm_features vae_grids: list[tuple[int, int]] = [] + vit_grids: list[tuple[int, int]] = [] if mm_features is not None: for mm_feature in mm_features: mm_item = mm_feature.data @@ -1438,20 +1544,22 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo if vae_hw is not None: grid = vae_hw.tolist() vae_grids.append((int(grid[0]), int(grid[1]))) + vit_hw = mm_input.get("vit_spatial_shapes") + if vit_hw is not None: + grid = vit_hw.tolist() + vit_grids.append((int(grid[0]), int(grid[1]))) - # Identify image token ids (cached from __init__) img_token_id = self._mrope_img_token_id boi_token_id = self._mrope_boi_token_id eoi_token_id = self._mrope_eoi_token_id joint_img_sep_token_id = self._mrope_joint_img_sep_token_id - max_num_patches = self._mrope_max_num_patches # Build position arrays - t_pos = [] # temporal (same as 1D for this model) - h_pos = [] # height - w_pos = [] # width + t_pos: list[int] = [] # temporal (same as 1D for this model) + h_pos: list[int] = [] # height + w_pos: list[int] = [] # width - pos = 0 # current 1D position counter + pos = 0 image_idx = 0 i = 0 n = len(input_tokens) @@ -1463,7 +1571,7 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo # Found start of image block. # Structure: *timestep *vae # *vit - # Assign a flat position + # token t_pos.append(pos) h_pos.append(pos) w_pos.append(pos) @@ -1494,16 +1602,12 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo pos += 1 i += 1 - # VAE tokens: get grid dims + # VAE tokens: 2D grid positions if image_idx < len(vae_grids): vae_h, vae_w = vae_grids[image_idx] else: vae_h, vae_w = 0, 0 - image_idx += 1 - - # Assign 2D positions to VAE tokens using build_2d_rope - # centering logic - L = pos # position at start of VAE region + L = pos wh = vae_w * vae_h beta_y = L + (wh - vae_h) / 2 beta_x = L + (wh - vae_w) / 2 @@ -1511,12 +1615,12 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo for row in range(vae_h): for col in range(vae_w): if i < n and input_tokens[i] == img_token_id: - t_pos.append(L) # temporal stays flat + t_pos.append(L) h_pos.append(int(beta_y + row)) w_pos.append(int(beta_x + col)) i += 1 - pos = L + wh # advance past VAE region + pos = L + wh # token if i < n and input_tokens[i] == joint_img_sep_token_id: @@ -1526,15 +1630,34 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo pos += 1 i += 1 - # ViT tokens (max_num_patches tokens) — flat 1D - vit_consumed = 0 - while i < n and input_tokens[i] == img_token_id and vit_consumed < max_num_patches: + # ViT tokens: 2D grid positions + if image_idx < len(vit_grids): + vit_h, vit_w = vit_grids[image_idx] + else: + vit_h, vit_w = 0, 0 + L = pos + wh = vit_w * vit_h + beta_y = L + (wh - vit_h) / 2 + beta_x = L + (wh - vit_w) / 2 + + for row in range(vit_h): + for col in range(vit_w): + if i < n and input_tokens[i] == img_token_id: + t_pos.append(L) + h_pos.append(int(beta_y + row)) + w_pos.append(int(beta_x + col)) + i += 1 + + pos = L + wh + + # Remaining ViT padding tokens (when max_num_patches > + # vit_h * vit_w) get flat 1D positions. + while i < n and input_tokens[i] == img_token_id: t_pos.append(pos) h_pos.append(pos) w_pos.append(pos) pos += 1 i += 1 - vit_consumed += 1 # token if i < n and input_tokens[i] == eoi_token_id: @@ -1544,6 +1667,7 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo pos += 1 i += 1 + image_idx += 1 else: # Regular text token — flat 1D position t_pos.append(pos) diff --git a/vllm_omni/model_executor/stage_configs/hunyuan_image_3_moe.yaml b/vllm_omni/model_executor/stage_configs/hunyuan_image_3_moe.yaml index b4b918288..e8a603af4 100644 --- a/vllm_omni/model_executor/stage_configs/hunyuan_image_3_moe.yaml +++ b/vllm_omni/model_executor/stage_configs/hunyuan_image_3_moe.yaml @@ -22,6 +22,10 @@ stage_args: max_num_batched_tokens: 32768 tensor_parallel_size: 8 pipeline_parallel_size: 1 + hf_overrides: + rope_parameters: + mrope_section: [0, 32, 32] + rope_type: default is_comprehension: true final_output: true final_output_type: text From 8ad8988e2b28fa98ee1f0773cf4c6060b71317a1 Mon Sep 17 00:00:00 2001 From: Du Bin Date: Thu, 12 Mar 2026 23:28:21 +0800 Subject: [PATCH 32/38] [Bugfix] Fix Helios text_encoder embed_tokens all-zeros due to untied weights (#1728) Signed-off-by: OSS Scout --- .../diffusion/models/helios/pipeline_helios.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/vllm_omni/diffusion/models/helios/pipeline_helios.py b/vllm_omni/diffusion/models/helios/pipeline_helios.py index ba15a62b0..9fc6dcf7f 100644 --- a/vllm_omni/diffusion/models/helios/pipeline_helios.py +++ b/vllm_omni/diffusion/models/helios/pipeline_helios.py @@ -17,7 +17,7 @@ import torch.nn.functional as F from diffusers import AutoencoderKLWan from diffusers.utils.torch_utils import randn_tensor from torch import nn -from transformers import AutoTokenizer, UMT5EncoderModel +from transformers import AutoConfig, AutoTokenizer, UMT5EncoderModel from vllm.model_executor.models.utils import AutoWeightsLoader from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig @@ -174,8 +174,17 @@ class HeliosPipeline(nn.Module, CFGParallelMixin, ProgressBarMixin): ] self.tokenizer = AutoTokenizer.from_pretrained(model, subfolder="tokenizer", local_files_only=local_files_only) + # Helios checkpoints store embed_tokens under ``shared.weight`` only, + # but the published config sets ``tie_word_embeddings=False``. When + # transformers sees ``tie=False`` it creates a separate + # ``encoder.embed_tokens.weight`` that is never loaded from the + # checkpoint, leaving it as all-zeros. This silently destroys prompt + # encoding and produces grey/meaningless video output. Force tying so + # that ``embed_tokens`` shares ``shared.weight`` as intended. + text_enc_cfg = AutoConfig.from_pretrained(model, subfolder="text_encoder", local_files_only=local_files_only) + text_enc_cfg.tie_word_embeddings = True self.text_encoder = UMT5EncoderModel.from_pretrained( - model, subfolder="text_encoder", torch_dtype=dtype, local_files_only=local_files_only + model, subfolder="text_encoder", config=text_enc_cfg, torch_dtype=dtype, local_files_only=local_files_only ).to(self.device) self.vae = AutoencoderKLWan.from_pretrained( model, subfolder="vae", torch_dtype=torch.float32, local_files_only=local_files_only From 9c6fbd76be4edeb37298952a983427d6e3abdef1 Mon Sep 17 00:00:00 2001 From: Yueqian Lin <70319226+linyueqian@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:30:20 -0400 Subject: [PATCH 33/38] Enable async_scheduling by default for Qwen3-TTS (#1853) Signed-off-by: linyueqian --- vllm_omni/model_executor/stage_configs/qwen3_tts.yaml | 4 ++-- vllm_omni/model_executor/stage_configs/qwen3_tts_batch.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/vllm_omni/model_executor/stage_configs/qwen3_tts.yaml b/vllm_omni/model_executor/stage_configs/qwen3_tts.yaml index 095aafe08..570ed710c 100644 --- a/vllm_omni/model_executor/stage_configs/qwen3_tts.yaml +++ b/vllm_omni/model_executor/stage_configs/qwen3_tts.yaml @@ -15,7 +15,7 @@ stage_args: scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler enforce_eager: false trust_remote_code: true - async_scheduling: false + async_scheduling: true enable_prefix_caching: false engine_output_type: latent gpu_memory_utilization: 0.3 @@ -50,7 +50,7 @@ stage_args: scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler enforce_eager: true trust_remote_code: true - async_scheduling: false + async_scheduling: true enable_prefix_caching: false engine_output_type: audio gpu_memory_utilization: 0.2 diff --git a/vllm_omni/model_executor/stage_configs/qwen3_tts_batch.yaml b/vllm_omni/model_executor/stage_configs/qwen3_tts_batch.yaml index d73739109..5236668dc 100644 --- a/vllm_omni/model_executor/stage_configs/qwen3_tts_batch.yaml +++ b/vllm_omni/model_executor/stage_configs/qwen3_tts_batch.yaml @@ -19,7 +19,7 @@ stage_args: scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler enforce_eager: false trust_remote_code: true - async_scheduling: false + async_scheduling: true enable_prefix_caching: false engine_output_type: latent gpu_memory_utilization: 0.3 @@ -54,7 +54,7 @@ stage_args: scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler enforce_eager: true trust_remote_code: true - async_scheduling: false + async_scheduling: true enable_prefix_caching: false engine_output_type: audio gpu_memory_utilization: 0.2 From 36c6cb9634306f0f250d691b41da422a97d0aff1 Mon Sep 17 00:00:00 2001 From: Gao Han Date: Fri, 13 Mar 2026 01:00:16 +0800 Subject: [PATCH 34/38] [CI failure] Comment out test_zimage_vae_patch_parallel_tp2 (#1856) Signed-off-by: Gao Han --- .../test_zimage_parallelism.py | 102 +++++++++--------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/tests/e2e/offline_inference/test_zimage_parallelism.py b/tests/e2e/offline_inference/test_zimage_parallelism.py index 4ee774bfd..63cb8743d 100644 --- a/tests/e2e/offline_inference/test_zimage_parallelism.py +++ b/tests/e2e/offline_inference/test_zimage_parallelism.py @@ -220,59 +220,59 @@ def test_zimage_tensor_parallel_tp2(tmp_path: Path): ) -@pytest.mark.integration -def test_zimage_vae_patch_parallel_tp2(tmp_path: Path): - if current_omni_platform.is_npu() or current_omni_platform.is_rocm(): - pytest.skip("Z-Image VAE patch parallel e2e test is only supported on CUDA for now.") - if not current_omni_platform.is_available() or current_omni_platform.device_count() < 2: - pytest.skip("Z-Image VAE patch parallel TP=2 requires >= 2 devices.") +# @pytest.mark.integration +# def test_zimage_vae_patch_parallel_tp2(tmp_path: Path): +# if current_omni_platform.is_npu() or current_omni_platform.is_rocm(): +# pytest.skip("Z-Image VAE patch parallel e2e test is only supported on CUDA for now.") +# if not current_omni_platform.is_available() or current_omni_platform.device_count() < 2: +# pytest.skip("Z-Image VAE patch parallel TP=2 requires >= 2 devices.") - enforce_eager = False +# enforce_eager = False - # Use a larger image to ensure there are multiple VAE tiles. - height = 1152 - width = 1152 - num_inference_steps = 2 - seed = 42 +# # Use a larger image to ensure there are multiple VAE tiles. +# height = 1152 +# width = 1152 +# num_inference_steps = 2 +# seed = 42 - baseline_img, _baseline_time_s, _baseline_peak_mem = _run_zimage_generate( - tp_size=2, - height=height, - width=width, - num_inference_steps=num_inference_steps, - seed=seed, - enforce_eager=enforce_eager, - vae_use_tiling=True, - vae_patch_parallel_size=1, - num_requests=2, - ) - pp2_img, _pp2_time_s, _pp2_peak_mem = _run_zimage_generate( - tp_size=2, - height=height, - width=width, - num_inference_steps=num_inference_steps, - seed=seed, - enforce_eager=enforce_eager, - vae_use_tiling=True, - vae_patch_parallel_size=2, - num_requests=2, - ) +# baseline_img, _baseline_time_s, _baseline_peak_mem = _run_zimage_generate( +# tp_size=2, +# height=height, +# width=width, +# num_inference_steps=num_inference_steps, +# seed=seed, +# enforce_eager=enforce_eager, +# vae_use_tiling=True, +# vae_patch_parallel_size=1, +# num_requests=2, +# ) +# pp2_img, _pp2_time_s, _pp2_peak_mem = _run_zimage_generate( +# tp_size=2, +# height=height, +# width=width, +# num_inference_steps=num_inference_steps, +# seed=seed, +# enforce_eager=enforce_eager, +# vae_use_tiling=True, +# vae_patch_parallel_size=2, +# num_requests=2, +# ) - baseline_path = tmp_path / "zimage_tp2_vae_pp1.png" - pp2_path = tmp_path / "zimage_tp2_vae_pp2.png" - baseline_img.save(baseline_path) - pp2_img.save(pp2_path) +# baseline_path = tmp_path / "zimage_tp2_vae_pp1.png" +# pp2_path = tmp_path / "zimage_tp2_vae_pp2.png" +# baseline_img.save(baseline_path) +# pp2_img.save(pp2_path) - mean_abs_diff, p99_abs_diff = _diff_metrics(baseline_img, pp2_img) - mean_threshold = 5e-3 - p99_threshold = 1e-1 - print( - "Z-Image VAE patch parallel image diff stats (TP=2, pp=1 vs pp=2): " - f"mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e}; " - f"thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e}; " - f"pp1_img={baseline_path}, pp2_img={pp2_path}" - ) - assert mean_abs_diff <= mean_threshold and p99_abs_diff <= p99_threshold, ( - f"Image diff exceeded threshold: mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e} " - f"(thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e})" - ) +# mean_abs_diff, p99_abs_diff = _diff_metrics(baseline_img, pp2_img) +# mean_threshold = 5e-3 +# p99_threshold = 1e-1 +# print( +# "Z-Image VAE patch parallel image diff stats (TP=2, pp=1 vs pp=2): " +# f"mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e}; " +# f"thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e}; " +# f"pp1_img={baseline_path}, pp2_img={pp2_path}" +# ) +# assert mean_abs_diff <= mean_threshold and p99_abs_diff <= p99_threshold, ( +# f"Image diff exceeded threshold: mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e} " +# f"(thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e})" +# ) From 366b3368eaf605820eabb28c574ab6af86f1891d Mon Sep 17 00:00:00 2001 From: Yueqian Lin <70319226+linyueqian@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:20:52 -0400 Subject: [PATCH 35/38] Add Fish Speech S2 Pro support with online serving and voice cloning (#1798) Signed-off-by: linyueqian --- docs/.nav.yml | 6 + docs/models/supported_models.md | 1 + docs/serving/speech_api.md | 26 +- .../examples/offline_inference/fish_speech.md | 97 +++ .../examples/online_serving/fish_speech.md | 194 +++++ .../offline_inference/fish_speech/README.md | 75 ++ .../offline_inference/fish_speech/end2end.py | 252 +++++++ examples/online_serving/fish_speech/README.md | 172 +++++ .../online_serving/fish_speech/gradio_demo.py | 272 +++++++ .../fish_speech/run_gradio_demo.sh | 48 ++ .../online_serving/fish_speech/run_server.sh | 23 + .../fish_speech/speech_client.py | 129 ++++ .../entrypoints/openai/serving_speech.py | 113 ++- .../models/fish_speech/__init__.py | 0 .../fish_speech/configuration_fish_speech.py | 154 ++++ .../models/fish_speech/dac_encoder.py | 111 +++ .../models/fish_speech/dac_utils.py | 92 +++ .../fish_speech/fish_speech_dac_decoder.py | 272 +++++++ .../models/fish_speech/fish_speech_fast_ar.py | 470 ++++++++++++ .../models/fish_speech/fish_speech_slow_ar.py | 669 ++++++++++++++++++ vllm_omni/model_executor/models/registry.py | 11 + .../stage_configs/fish_speech_s2_pro.yaml | 97 +++ .../stage_input_processors/fish_speech.py | 151 ++++ .../transformers_utils/configs/__init__.py | 7 + .../transformers_utils/configs/fish_speech.py | 24 + 25 files changed, 3459 insertions(+), 7 deletions(-) create mode 100644 docs/user_guide/examples/offline_inference/fish_speech.md create mode 100644 docs/user_guide/examples/online_serving/fish_speech.md create mode 100644 examples/offline_inference/fish_speech/README.md create mode 100644 examples/offline_inference/fish_speech/end2end.py create mode 100644 examples/online_serving/fish_speech/README.md create mode 100644 examples/online_serving/fish_speech/gradio_demo.py create mode 100755 examples/online_serving/fish_speech/run_gradio_demo.sh create mode 100755 examples/online_serving/fish_speech/run_server.sh create mode 100644 examples/online_serving/fish_speech/speech_client.py create mode 100644 vllm_omni/model_executor/models/fish_speech/__init__.py create mode 100644 vllm_omni/model_executor/models/fish_speech/configuration_fish_speech.py create mode 100644 vllm_omni/model_executor/models/fish_speech/dac_encoder.py create mode 100644 vllm_omni/model_executor/models/fish_speech/dac_utils.py create mode 100644 vllm_omni/model_executor/models/fish_speech/fish_speech_dac_decoder.py create mode 100644 vllm_omni/model_executor/models/fish_speech/fish_speech_fast_ar.py create mode 100644 vllm_omni/model_executor/models/fish_speech/fish_speech_slow_ar.py create mode 100644 vllm_omni/model_executor/stage_configs/fish_speech_s2_pro.yaml create mode 100644 vllm_omni/model_executor/stage_input_processors/fish_speech.py create mode 100644 vllm_omni/transformers_utils/configs/fish_speech.py diff --git a/docs/.nav.yml b/docs/.nav.yml index 4aa8fee43..b9f39ebfa 100644 --- a/docs/.nav.yml +++ b/docs/.nav.yml @@ -13,9 +13,13 @@ nav: - examples/README.md - Offline Inference: - BAGEL-7B-MoT: user_guide/examples/offline_inference/bagel.md + - Fish Speech S2 Pro: user_guide/examples/offline_inference/fish_speech.md - GLM-Image Multistage End-to-End Inference: user_guide/examples/offline_inference/glm_image.md + - Helios Video Generation: user_guide/examples/offline_inference/helios.md + - HunyuanImage-3.0 Image-to-Text Inference: user_guide/examples/offline_inference/hunyuan_image3.md - Image-To-Image: user_guide/examples/offline_inference/image_to_image.md - Image-To-Video: user_guide/examples/offline_inference/image_to_video.md + - MammothModa2-Preview: user_guide/examples/offline_inference/mammothmodal2_preview.md - MiMo-Audio Offline Inference: user_guide/examples/offline_inference/mimo_audio.md - Qwen2.5-Omni: user_guide/examples/offline_inference/qwen2_5_omni.md - Qwen3-Omni: user_guide/examples/offline_inference/qwen3_omni.md @@ -25,6 +29,8 @@ nav: - Text-To-Video: user_guide/examples/offline_inference/text_to_video.md - Online Serving: - BAGEL-7B-MoT: user_guide/examples/online_serving/bagel.md + - vLLM-Omni Helm Chart: user_guide/examples/online_serving/chart-helm.md + - Fish Speech S2 Pro: user_guide/examples/online_serving/fish_speech.md - GLM-Image Online Serving: user_guide/examples/online_serving/glm_image.md - Image-To-Image: user_guide/examples/online_serving/image_to_image.md - Image-To-Video: user_guide/examples/online_serving/image_to_video.md diff --git a/docs/models/supported_models.md b/docs/models/supported_models.md index 52e75989d..4c709425e 100644 --- a/docs/models/supported_models.md +++ b/docs/models/supported_models.md @@ -50,6 +50,7 @@ th { |`NextStep11Pipeline` | NextStep-1.1 | `stepfun-ai/NextStep-1.1` | |`MiMoAudioForConditionalGeneration` | MiMo-Audio-7B-Instruct | `XiaomiMiMo/MiMo-Audio-7B-Instruct` | |`Flux2Pipeline` | FLUX.2-dev | `black-forest-labs/FLUX.2-dev` | +|`FishSpeechSlowARForConditionalGeneration` | Fish Speech S2 Pro | `fishaudio/s2-pro` | ## List of Supported Models for NPU diff --git a/docs/serving/speech_api.md b/docs/serving/speech_api.md index c346b5b97..46b9154bb 100644 --- a/docs/serving/speech_api.md +++ b/docs/serving/speech_api.md @@ -1,6 +1,9 @@ # Speech API -vLLM-Omni provides an OpenAI-compatible API for text-to-speech (TTS) generation using Qwen3-TTS models. +vLLM-Omni provides an OpenAI-compatible API for text-to-speech (TTS) generation. Supported TTS models include: + +- **Qwen3-TTS** (`Qwen/Qwen3-TTS-12Hz-*`) -- Qwen3-based TTS with CustomVoice, VoiceDesign, and Base (voice cloning) task types. Output: 24 kHz. +- **Fish Speech S2 Pro** (`fishaudio/s2-pro`) -- Dual-AR TTS with DAC codec. Supports text-to-speech and voice cloning via reference audio. Output: 44.1 kHz. Each server instance runs a single model (specified at startup via `vllm serve --omni`). @@ -9,13 +12,22 @@ Each server instance runs a single model (specified at startup via `vllm serve < ### Start the Server ```bash -# CustomVoice model (predefined speakers) +# Qwen3-TTS: CustomVoice model (predefined speakers) vllm serve Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice \ --stage-configs-path vllm_omni/model_executor/stage_configs/qwen3_tts.yaml \ --omni \ --port 8091 \ --trust-remote-code \ --enforce-eager + +# Fish Speech S2 Pro +vllm-omni serve fishaudio/s2-pro \ + --stage-configs-path vllm_omni/model_executor/stage_configs/fish_speech_s2_pro.yaml \ + --omni \ + --port 8091 \ + --trust-remote-code \ + --enforce-eager \ + --gpu-memory-utilization 0.9 ``` ### Generate Speech @@ -317,6 +329,8 @@ curl -X POST http://localhost:8091/v1/audio/speech \ ## Supported Models +### Qwen3-TTS + | Model | Task Type | Description | |-------|-----------|-------------| | `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice` | CustomVoice | Predefined speaker voices with optional style control | @@ -325,6 +339,14 @@ curl -X POST http://localhost:8091/v1/audio/speech \ | `Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice` | CustomVoice | Smaller/faster variant | | `Qwen/Qwen3-TTS-12Hz-0.6B-Base` | Base | Smaller/faster variant for voice cloning | +### Fish Speech S2 Pro + +| Model | Description | +|-------|-------------| +| `fishaudio/s2-pro` | 4B dual-AR TTS with DAC codec (44.1 kHz). Supports text-to-speech and voice cloning. | + +Fish Speech uses `ref_audio` and `ref_text` for voice cloning (no `task_type` needed). The `voice` field should be set to `"default"`. See the [Fish Speech online serving example](../user_guide/examples/online_serving/fish_speech.md) for details. + ## Error Responses ### 400 Bad Request diff --git a/docs/user_guide/examples/offline_inference/fish_speech.md b/docs/user_guide/examples/offline_inference/fish_speech.md new file mode 100644 index 000000000..dd9066afd --- /dev/null +++ b/docs/user_guide/examples/offline_inference/fish_speech.md @@ -0,0 +1,97 @@ +# Fish Speech S2 Pro + +Source . + + +This directory contains an offline demo for running Fish Speech S2 Pro (`fishaudio/s2-pro`) with vLLM Omni. It supports text-only synthesis and voice cloning with reference audio. + +## Model Overview + +[Fish Speech S2 Pro](https://huggingface.co/fishaudio/s2-pro) is a 4B dual-AR text-to-speech model by FishAudio. It uses: + +- **Slow AR**: Generates semantic tokens autoregressively (Qwen3-based backbone) +- **Fast AR**: Predicts residual codebook tokens from semantic tokens +- **DAC Decoder**: Converts codec codes to 44.1 kHz audio waveform + +The model produces high-quality speech with voice cloning capabilities. + +## Prerequisites + +Install the `fish-speech` package for the DAC codec: + +```bash +pip install fish-speech +``` + +## Setup + +Please refer to the [stage configuration documentation](https://docs.vllm.ai/projects/vllm-omni/en/latest/configuration/stage_configs/) to configure memory allocation appropriately for your hardware setup. + +## Quick Start + +### Text-Only Synthesis + +```bash +python end2end.py --text "Hello, this is a test of the Fish Speech text to speech system." +``` + +Generated audio files are saved to `output_audio/` by default. + +### Voice Cloning + +Provide a reference audio file and its transcript to clone a voice: + +```bash +python end2end.py \ + --text "Hello, this is a cloned voice." \ + --ref-audio /path/to/reference.wav \ + --ref-text "Transcript of the reference audio." +``` + +## Streaming Mode + +Add `--streaming` to stream audio chunks progressively via `AsyncOmni` (requires `async_chunk: true` in the stage config): + +```bash +python end2end.py --text "Hello, this is a test." --streaming +``` + +Each DAC decoder chunk is logged as it arrives. The final WAV file is written once generation completes. + +## Configuration + +| Argument | Default | Description | +|----------|---------|-------------| +| `--text` | `"Hello, this is a test..."` | Text to synthesize | +| `--ref-audio` | None | Path to reference audio for voice cloning | +| `--ref-text` | None | Transcript of the reference audio | +| `--model` | `fishaudio/s2-pro` | HuggingFace model path | +| `--stage-configs-path` | `fish_speech_s2_pro.yaml` | Path to stage configs YAML | +| `--output-dir` | `output_audio` | Output directory for WAV files | +| `--streaming` | False | Enable streaming via AsyncOmni | +| `--stage-init-timeout` | 600 | Stage initialization timeout (seconds) | + +## Notes + +- Output audio is 44.1 kHz mono WAV. +- The DAC codec weights (`codec.pth`) are loaded lazily from the model directory. +- Voice cloning encodes the reference audio using the DAC codec to extract semantic codes, then prepends them as a system message. + +## Example materials + +??? abstract "diag_codebook.py" + ``````py + --8<-- "examples/offline_inference/fish_speech/diag_codebook.py" + `````` +??? abstract "diag_reference_compare.py" + ``````py + --8<-- "examples/offline_inference/fish_speech/diag_reference_compare.py" + `````` +??? abstract "end2end.py" + ``````py + --8<-- "examples/offline_inference/fish_speech/end2end.py" + `````` +??? abstract "standalone_test.py" + ``````py + --8<-- "examples/offline_inference/fish_speech/standalone_test.py" + `````` diff --git a/docs/user_guide/examples/online_serving/fish_speech.md b/docs/user_guide/examples/online_serving/fish_speech.md new file mode 100644 index 000000000..2c76b1bca --- /dev/null +++ b/docs/user_guide/examples/online_serving/fish_speech.md @@ -0,0 +1,194 @@ +# Fish Speech S2 Pro + +Source . + + +## Model + +| Model | Description | +|-------|-------------| +| `fishaudio/s2-pro` | Fish Speech S2 Pro -- 4B dual-AR TTS model with DAC codec (44.1 kHz) | + +## Gradio Demo + +An interactive Gradio demo is available with text-to-speech synthesis, voice cloning, and streaming support. + +```bash +# Option 1: Launch server + Gradio together +./run_gradio_demo.sh + +# Option 2: If server is already running +python gradio_demo.py --api-base http://localhost:8091 +``` + +Then open http://localhost:7860 in your browser. + +Features: + +- Text-to-speech synthesis +- Voice cloning from uploaded audio or URL +- Streaming mode (progressive PCM playback) + +## Launch the Server + +```bash +vllm-omni serve fishaudio/s2-pro \ + --stage-configs-path vllm_omni/model_executor/stage_configs/fish_speech_s2_pro.yaml \ + --omni \ + --port 8091 \ + --trust-remote-code \ + --enforce-eager \ + --gpu-memory-utilization 0.9 +``` + +Or use the convenience script: + +```bash +./run_server.sh +``` + +## Send TTS Request + +### Using curl + +```bash +# Basic TTS +curl -X POST http://localhost:8091/v1/audio/speech \ + -H "Content-Type: application/json" \ + -d '{ + "input": "Hello, how are you?", + "voice": "default", + "response_format": "wav" + }' --output output.wav +``` + +### Voice Cloning + +Provide a reference audio (URL or base64 data URL) and its transcript: + +```bash +curl -X POST http://localhost:8091/v1/audio/speech \ + -H "Content-Type: application/json" \ + -d '{ + "input": "Hello, this is a cloned voice.", + "voice": "default", + "ref_audio": "https://example.com/reference.wav", + "ref_text": "Transcript of the reference audio." + }' --output cloned.wav +``` + +### Using Python + +```python +import httpx + +# Basic TTS +response = httpx.post( + "http://localhost:8091/v1/audio/speech", + json={ + "input": "Hello, how are you?", + "voice": "default", + "response_format": "wav", + }, + timeout=300.0, +) + +with open("output.wav", "wb") as f: + f.write(response.content) +``` + +### Using the CLI Client + +```bash +cd examples/online_serving/fish_speech + +# Basic TTS +python speech_client.py --text "Hello, how are you?" + +# Voice cloning +python speech_client.py \ + --text "Hello, this is a cloned voice." \ + --ref-audio /path/to/reference.wav \ + --ref-text "Transcript of the reference audio." + +# Streaming PCM output +python speech_client.py --text "Hello world" --stream --output output.pcm +``` + +The CLI client supports: + +- `--api-base`: API base URL (default: `http://localhost:8091`) +- `--model` (or `-m`): Model name (default: `fishaudio/s2-pro`) +- `--text`: Text to synthesize (required) +- `--ref-audio`: Reference audio for voice cloning (local path or URL) +- `--ref-text`: Transcript of the reference audio +- `--stream`: Enable streaming (PCM output) +- `--response-format`: Audio format: wav, mp3, flac, pcm, aac, opus (default: wav) +- `--output` (or `-o`): Output file path + +## Streaming + +Set `stream=true` with `response_format="pcm"` to receive raw PCM audio chunks as they are decoded: + +```bash +curl -X POST http://localhost:8091/v1/audio/speech \ + -H "Content-Type: application/json" \ + -d '{ + "input": "Hello, how are you?", + "voice": "default", + "stream": true, + "response_format": "pcm" + }' --no-buffer | play -t raw -r 44100 -e signed -b 16 -c 1 - +``` + +**Note:** Fish Speech outputs at 44.1 kHz (unlike Qwen3-TTS which outputs at 24 kHz). + +## API Parameters + +Fish Speech uses the same `/v1/audio/speech` endpoint as Qwen3-TTS. See the [Speech API reference](https://docs.vllm.ai/projects/vllm-omni/en/latest/serving/speech_api/) for full parameter documentation. + +Key parameters for Fish Speech: + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `input` | string | **required** | Text to synthesize | +| `voice` | string | "default" | Voice name (use "default" for Fish Speech) | +| `response_format` | string | "wav" | Audio format: wav, mp3, flac, pcm, aac, opus | +| `ref_audio` | string | null | Reference audio URL or base64 data URL for voice cloning | +| `ref_text` | string | null | Transcript of reference audio (required for voice cloning) | +| `max_new_tokens` | int | 4096 | Maximum tokens to generate | +| `stream` | bool | false | Stream raw PCM chunks | + +## Prerequisites + +Install the `fish-speech` package for the DAC codec: + +```bash +pip install fish-speech +``` + +## Troubleshooting + +1. **No audio output**: Make sure the `fish-speech` package is installed for the DAC decoder +2. **Connection refused**: Ensure the server is running on the correct port +3. **Flashinfer version mismatch**: Set `FLASHINFER_DISABLE_VERSION_CHECK=1` if you see version warnings +4. **Out of memory**: Reduce `--gpu-memory-utilization` or use a GPU with more VRAM + +## Example materials + +??? abstract "gradio_demo.py" + ``````py + --8<-- "examples/online_serving/fish_speech/gradio_demo.py" + `````` +??? abstract "run_gradio_demo.sh" + ``````sh + --8<-- "examples/online_serving/fish_speech/run_gradio_demo.sh" + `````` +??? abstract "run_server.sh" + ``````sh + --8<-- "examples/online_serving/fish_speech/run_server.sh" + `````` +??? abstract "speech_client.py" + ``````py + --8<-- "examples/online_serving/fish_speech/speech_client.py" + `````` diff --git a/examples/offline_inference/fish_speech/README.md b/examples/offline_inference/fish_speech/README.md new file mode 100644 index 000000000..9af853096 --- /dev/null +++ b/examples/offline_inference/fish_speech/README.md @@ -0,0 +1,75 @@ +# Fish Speech S2 Pro + +This directory contains an offline demo for running Fish Speech S2 Pro (`fishaudio/s2-pro`) with vLLM Omni. It supports text-only synthesis and voice cloning with reference audio. + +## Model Overview + +[Fish Speech S2 Pro](https://huggingface.co/fishaudio/s2-pro) is a 4B dual-AR text-to-speech model by FishAudio. It uses: + +- **Slow AR**: Generates semantic tokens autoregressively (Qwen3-based backbone) +- **Fast AR**: Predicts residual codebook tokens from semantic tokens +- **DAC Decoder**: Converts codec codes to 44.1 kHz audio waveform + +The model produces high-quality speech with voice cloning capabilities. + +## Prerequisites + +Install the `fish-speech` package for the DAC codec: + +```bash +pip install fish-speech +``` + +## Setup + +Please refer to the [stage configuration documentation](https://docs.vllm.ai/projects/vllm-omni/en/latest/configuration/stage_configs/) to configure memory allocation appropriately for your hardware setup. + +## Quick Start + +### Text-Only Synthesis + +```bash +python end2end.py --text "Hello, this is a test of the Fish Speech text to speech system." +``` + +Generated audio files are saved to `output_audio/` by default. + +### Voice Cloning + +Provide a reference audio file and its transcript to clone a voice: + +```bash +python end2end.py \ + --text "Hello, this is a cloned voice." \ + --ref-audio /path/to/reference.wav \ + --ref-text "Transcript of the reference audio." +``` + +## Streaming Mode + +Add `--streaming` to stream audio chunks progressively via `AsyncOmni` (requires `async_chunk: true` in the stage config): + +```bash +python end2end.py --text "Hello, this is a test." --streaming +``` + +Each DAC decoder chunk is logged as it arrives. The final WAV file is written once generation completes. + +## Configuration + +| Argument | Default | Description | +|----------|---------|-------------| +| `--text` | `"Hello, this is a test..."` | Text to synthesize | +| `--ref-audio` | None | Path to reference audio for voice cloning | +| `--ref-text` | None | Transcript of the reference audio | +| `--model` | `fishaudio/s2-pro` | HuggingFace model path | +| `--stage-configs-path` | `fish_speech_s2_pro.yaml` | Path to stage configs YAML | +| `--output-dir` | `output_audio` | Output directory for WAV files | +| `--streaming` | False | Enable streaming via AsyncOmni | +| `--stage-init-timeout` | 600 | Stage initialization timeout (seconds) | + +## Notes + +- Output audio is 44.1 kHz mono WAV. +- The DAC codec weights (`codec.pth`) are loaded lazily from the model directory. +- Voice cloning encodes the reference audio using the DAC codec to extract semantic codes, then prepends them as a system message. diff --git a/examples/offline_inference/fish_speech/end2end.py b/examples/offline_inference/fish_speech/end2end.py new file mode 100644 index 000000000..9e95f7e5c --- /dev/null +++ b/examples/offline_inference/fish_speech/end2end.py @@ -0,0 +1,252 @@ +"""Offline inference demo for Fish Speech S2 Pro via vLLM Omni. + +Generates speech from text using the fishaudio/s2-pro model. +Supports both text-only synthesis and voice cloning with reference audio. + +Usage: + # Text-only synthesis + python end2end.py --text "Hello, this is a test." + + # Voice cloning with reference audio + python end2end.py --text "Hello, this is a test." --ref-audio ref.wav --ref-text "Reference text." + + # Streaming mode + python end2end.py --text "Hello, this is a test." --streaming +""" + +import asyncio +import logging +import os +import time + +import soundfile as sf +import torch + +os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" + +from vllm.utils.argparse_utils import FlexibleArgumentParser + +from vllm_omni import AsyncOmni, Omni + +logger = logging.getLogger(__name__) + +DEFAULT_MODEL = "fishaudio/s2-pro" +DEFAULT_STAGE_CONFIG = os.path.join( + os.path.dirname(__file__), + "..", + "..", + "..", + "vllm_omni", + "model_executor", + "stage_configs", + "fish_speech_s2_pro.yaml", +) + + +def build_prompt( + text: str, + ref_audio_path: str | None = None, + ref_text: str | None = None, + model_name: str = DEFAULT_MODEL, +) -> dict: + """Build a prompt for Fish Speech S2 Pro. + + Uses Qwen3 chat template format with <|voice|> modality marker: + <|im_start|>user\n<|speaker:0|>text<|im_end|>\n<|im_start|>assistant\n<|voice|> + + The model then generates semantic tokens autoregressively. + """ + from transformers import AutoTokenizer + + tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True) + + # Build user message with speaker tag prefix. + user_text = f"<|speaker:0|>{text}" + + # Use chat template for user turn + assistant generation prompt. + messages = [{"role": "user", "content": user_text}] + prompt_ids = tokenizer.apply_chat_template( + messages, + tokenize=True, + add_generation_prompt=True, + ) + + # Append <|voice|> token (151673) to signal voice generation mode. + voice_token_id = tokenizer.encode("<|voice|>", add_special_tokens=False) + prompt_ids = prompt_ids + voice_token_id + + additional_information = { + "text": [text], + "max_new_tokens": [4096], + } + + if ref_audio_path: + additional_information["ref_audio"] = [ref_audio_path] + if ref_text: + additional_information["ref_text"] = [ref_text] + + return { + "prompt_token_ids": prompt_ids, + "additional_information": additional_information, + } + + +def _save_wav(output_dir: str, request_id: str, mm: dict) -> None: + """Concatenate audio chunks and write to a wav file.""" + audio_data = mm["audio"] + sr_raw = mm["sr"] + sr_val = sr_raw[-1] if isinstance(sr_raw, list) and sr_raw else sr_raw + sr = sr_val.item() if hasattr(sr_val, "item") else int(sr_val) + audio_tensor = torch.cat(audio_data, dim=-1) if isinstance(audio_data, list) else audio_data + out_wav = os.path.join(output_dir, f"output_{request_id}.wav") + sf.write(out_wav, audio_tensor.float().cpu().numpy().flatten(), samplerate=sr, format="WAV") + logger.info("Request %s: saved audio to %s (sr=%d)", request_id, out_wav, sr) + + +def main(args): + """Run offline inference with Omni.""" + output_dir = args.output_dir + os.makedirs(output_dir, exist_ok=True) + + stage_configs_path = args.stage_configs_path or DEFAULT_STAGE_CONFIG + model_name = args.model or DEFAULT_MODEL + + inputs = [ + build_prompt( + text=args.text, + ref_audio_path=args.ref_audio, + ref_text=args.ref_text, + model_name=model_name, + ) + ] + + omni = Omni( + model=model_name, + stage_configs_path=stage_configs_path, + log_stats=args.log_stats, + stage_init_timeout=args.stage_init_timeout, + ) + + t_start = time.perf_counter() + for stage_outputs in omni.generate(inputs): + for output in stage_outputs.request_output: + _save_wav(output_dir, output.request_id, output.outputs[0].multimodal_output) + t_end = time.perf_counter() + logger.info("Total inference time: %.1f ms", (t_end - t_start) * 1000) + + +async def main_streaming(args): + """Run offline inference with AsyncOmni for streaming audio chunks.""" + output_dir = args.output_dir + os.makedirs(output_dir, exist_ok=True) + + stage_configs_path = args.stage_configs_path or DEFAULT_STAGE_CONFIG + model_name = args.model or DEFAULT_MODEL + + prompt = build_prompt( + text=args.text, + ref_audio_path=args.ref_audio, + ref_text=args.ref_text, + model_name=model_name, + ) + + omni = AsyncOmni( + model=model_name, + stage_configs_path=stage_configs_path, + log_stats=args.log_stats, + stage_init_timeout=args.stage_init_timeout, + ) + + request_id = "0" + t_start = time.perf_counter() + t_prev = t_start + chunk_idx = 0 + + async for stage_output in omni.generate(prompt, request_id=request_id): + mm = stage_output.request_output.outputs[0].multimodal_output + if not stage_output.finished: + t_now = time.perf_counter() + audio = mm.get("audio") + n = len(audio) if isinstance(audio, list) else (0 if audio is None else 1) + dt_ms = (t_now - t_prev) * 1000 + ttfa_ms = (t_now - t_start) * 1000 + if chunk_idx == 0: + logger.info("Request %s: chunk %d samples=%d TTFA=%.1fms", request_id, chunk_idx, n, ttfa_ms) + else: + logger.info("Request %s: chunk %d samples=%d inter_chunk=%.1fms", request_id, chunk_idx, n, dt_ms) + t_prev = t_now + chunk_idx += 1 + else: + t_end = time.perf_counter() + total_ms = (t_end - t_start) * 1000 + logger.info("Request %s: done total=%.1fms chunks=%d", request_id, total_ms, chunk_idx) + _save_wav(output_dir, request_id, mm) + + +def parse_args(): + parser = FlexibleArgumentParser( + description="Fish Speech S2 Pro offline inference with vLLM Omni", + ) + parser.add_argument( + "--text", + type=str, + default="Hello, this is a test of the Fish Speech text to speech system.", + help="Text to synthesize.", + ) + parser.add_argument( + "--ref-audio", + type=str, + default=None, + help="Path to reference audio for voice cloning.", + ) + parser.add_argument( + "--ref-text", + type=str, + default=None, + help="Reference text corresponding to the reference audio.", + ) + parser.add_argument( + "--model", + type=str, + default=None, + help=f"HuggingFace model path (default: {DEFAULT_MODEL}).", + ) + parser.add_argument( + "--stage-configs-path", + type=str, + default=None, + help="Path to stage configs YAML.", + ) + parser.add_argument( + "--output-dir", + default="output_audio", + help="Output directory for generated wav files.", + ) + parser.add_argument( + "--log-stats", + action="store_true", + default=False, + help="Enable writing detailed statistics.", + ) + parser.add_argument( + "--stage-init-timeout", + type=int, + default=600, + help="Timeout for initializing stages in seconds.", + ) + parser.add_argument( + "--streaming", + action="store_true", + default=False, + help="Stream audio chunks as they arrive via AsyncOmni.", + ) + return parser.parse_args() + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + args = parse_args() + if args.streaming: + asyncio.run(main_streaming(args)) + else: + main(args) diff --git a/examples/online_serving/fish_speech/README.md b/examples/online_serving/fish_speech/README.md new file mode 100644 index 000000000..ae968d3ba --- /dev/null +++ b/examples/online_serving/fish_speech/README.md @@ -0,0 +1,172 @@ +# Fish Speech S2 Pro + +## Model + +| Model | Description | +|-------|-------------| +| `fishaudio/s2-pro` | Fish Speech S2 Pro -- 4B dual-AR TTS model with DAC codec (44.1 kHz) | + +## Gradio Demo + +An interactive Gradio demo is available with text-to-speech synthesis, voice cloning, and streaming support. + +```bash +# Option 1: Launch server + Gradio together +./run_gradio_demo.sh + +# Option 2: If server is already running +python gradio_demo.py --api-base http://localhost:8091 +``` + +Then open http://localhost:7860 in your browser. + +Features: + +- Text-to-speech synthesis +- Voice cloning from uploaded audio or URL +- Streaming mode (progressive PCM playback) + +## Launch the Server + +```bash +vllm-omni serve fishaudio/s2-pro \ + --stage-configs-path vllm_omni/model_executor/stage_configs/fish_speech_s2_pro.yaml \ + --omni \ + --port 8091 \ + --trust-remote-code \ + --enforce-eager \ + --gpu-memory-utilization 0.9 +``` + +Or use the convenience script: + +```bash +./run_server.sh +``` + +## Send TTS Request + +### Using curl + +```bash +# Basic TTS +curl -X POST http://localhost:8091/v1/audio/speech \ + -H "Content-Type: application/json" \ + -d '{ + "input": "Hello, how are you?", + "voice": "default", + "response_format": "wav" + }' --output output.wav +``` + +### Voice Cloning + +Provide a reference audio (URL or base64 data URL) and its transcript: + +```bash +curl -X POST http://localhost:8091/v1/audio/speech \ + -H "Content-Type: application/json" \ + -d '{ + "input": "Hello, this is a cloned voice.", + "voice": "default", + "ref_audio": "https://example.com/reference.wav", + "ref_text": "Transcript of the reference audio." + }' --output cloned.wav +``` + +### Using Python + +```python +import httpx + +# Basic TTS +response = httpx.post( + "http://localhost:8091/v1/audio/speech", + json={ + "input": "Hello, how are you?", + "voice": "default", + "response_format": "wav", + }, + timeout=300.0, +) + +with open("output.wav", "wb") as f: + f.write(response.content) +``` + +### Using the CLI Client + +```bash +cd examples/online_serving/fish_speech + +# Basic TTS +python speech_client.py --text "Hello, how are you?" + +# Voice cloning +python speech_client.py \ + --text "Hello, this is a cloned voice." \ + --ref-audio /path/to/reference.wav \ + --ref-text "Transcript of the reference audio." + +# Streaming PCM output +python speech_client.py --text "Hello world" --stream --output output.pcm +``` + +The CLI client supports: + +- `--api-base`: API base URL (default: `http://localhost:8091`) +- `--model` (or `-m`): Model name (default: `fishaudio/s2-pro`) +- `--text`: Text to synthesize (required) +- `--ref-audio`: Reference audio for voice cloning (local path or URL) +- `--ref-text`: Transcript of the reference audio +- `--stream`: Enable streaming (PCM output) +- `--response-format`: Audio format: wav, mp3, flac, pcm, aac, opus (default: wav) +- `--output` (or `-o`): Output file path + +## Streaming + +Set `stream=true` with `response_format="pcm"` to receive raw PCM audio chunks as they are decoded: + +```bash +curl -X POST http://localhost:8091/v1/audio/speech \ + -H "Content-Type: application/json" \ + -d '{ + "input": "Hello, how are you?", + "voice": "default", + "stream": true, + "response_format": "pcm" + }' --no-buffer | play -t raw -r 44100 -e signed -b 16 -c 1 - +``` + +**Note:** Fish Speech outputs at 44.1 kHz (unlike Qwen3-TTS which outputs at 24 kHz). + +## API Parameters + +Fish Speech uses the same `/v1/audio/speech` endpoint as Qwen3-TTS. See the [Speech API reference](https://docs.vllm.ai/projects/vllm-omni/en/latest/serving/speech_api/) for full parameter documentation. + +Key parameters for Fish Speech: + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `input` | string | **required** | Text to synthesize | +| `voice` | string | "default" | Voice name (use "default" for Fish Speech) | +| `response_format` | string | "wav" | Audio format: wav, mp3, flac, pcm, aac, opus | +| `ref_audio` | string | null | Reference audio URL or base64 data URL for voice cloning | +| `ref_text` | string | null | Transcript of reference audio (required for voice cloning) | +| `max_new_tokens` | int | 4096 | Maximum tokens to generate | +| `stream` | bool | false | Stream raw PCM chunks | + +## Prerequisites + +Install the `fish-speech` package for the DAC codec: + +```bash +pip install fish-speech +``` + +## Troubleshooting + +1. **No audio output**: Make sure the `fish-speech` package is installed for the DAC decoder +2. **Connection refused**: Ensure the server is running on the correct port +3. **Flashinfer version mismatch**: Set `FLASHINFER_DISABLE_VERSION_CHECK=1` if you see version warnings +4. **Out of memory**: Reduce `--gpu-memory-utilization` or use a GPU with more VRAM diff --git a/examples/online_serving/fish_speech/gradio_demo.py b/examples/online_serving/fish_speech/gradio_demo.py new file mode 100644 index 000000000..a8e51b274 --- /dev/null +++ b/examples/online_serving/fish_speech/gradio_demo.py @@ -0,0 +1,272 @@ +"""Gradio demo for Fish Speech S2 Pro online serving via /v1/audio/speech API. + +Supports: + - Text-to-speech synthesis + - Voice cloning from reference audio (upload or URL) + - Streaming (progressive PCM chunks) and non-streaming modes + +Usage: + # Start the server first (see run_server.sh), then: + python gradio_demo.py --api-base http://localhost:8091 + + # Or use run_gradio_demo.sh to start both server and demo together. +""" + +import argparse +import base64 +import io + +import gradio as gr +import httpx +import numpy as np +import soundfile as sf + +PCM_SAMPLE_RATE = 44100 +DEFAULT_API_BASE = "http://localhost:8091" + + +def encode_audio_to_base64(audio_data: tuple) -> str: + """Encode Gradio audio input (sample_rate, numpy_array) to base64 data URL.""" + sample_rate, audio_np = audio_data + if audio_np.dtype != np.int16: + if audio_np.dtype in (np.float32, np.float64): + audio_np = np.clip(audio_np, -1.0, 1.0) + audio_np = (audio_np * 32767).astype(np.int16) + else: + audio_np = audio_np.astype(np.int16) + buf = io.BytesIO() + sf.write(buf, audio_np, sample_rate, format="WAV") + wav_b64 = base64.b64encode(buf.getvalue()).decode("utf-8") + return f"data:audio/wav;base64,{wav_b64}" + + +def build_payload( + text: str, + ref_audio: tuple | None, + ref_audio_url: str, + ref_text: str, + response_format: str = "wav", + stream: bool = False, +) -> dict: + """Build the /v1/audio/speech request payload.""" + if not text or not text.strip(): + raise gr.Error("Please enter text to synthesize.") + + payload: dict = { + "input": text.strip(), + "voice": "default", + "response_format": "pcm" if stream else response_format, + "stream": stream, + } + + # Voice cloning: ref_audio takes priority over URL. + ref_url_stripped = ref_audio_url.strip() if ref_audio_url else "" + if ref_audio is not None: + payload["ref_audio"] = encode_audio_to_base64(ref_audio) + elif ref_url_stripped: + payload["ref_audio"] = ref_url_stripped + + if "ref_audio" in payload: + if not ref_text or not ref_text.strip(): + raise gr.Error("Voice cloning requires a transcript of the reference audio.") + payload["ref_text"] = ref_text.strip() + + return payload + + +def generate_speech(api_base: str, text: str, ref_audio, ref_audio_url, ref_text, response_format): + """Non-streaming: call /v1/audio/speech and return full audio.""" + payload = build_payload(text, ref_audio, ref_audio_url, ref_text, response_format, stream=False) + + try: + with httpx.Client(timeout=300.0) as client: + resp = client.post( + f"{api_base}/v1/audio/speech", + json=payload, + headers={"Content-Type": "application/json", "Authorization": "Bearer EMPTY"}, + ) + except httpx.TimeoutException: + raise gr.Error("Request timed out. The server may be busy.") + except httpx.ConnectError: + raise gr.Error(f"Cannot connect to server at {api_base}. Is the server running?") + + if resp.status_code != 200: + raise gr.Error(f"Server error ({resp.status_code}): {resp.text}") + + content_type = resp.headers.get("content-type", "") + if "application/json" in content_type: + try: + raise gr.Error(f"Server error: {resp.json()}") + except ValueError: + pass + + try: + if response_format == "pcm": + audio_np = np.frombuffer(resp.content, dtype=np.int16).astype(np.float32) / 32767.0 + return (PCM_SAMPLE_RATE, audio_np) + audio_np, sample_rate = sf.read(io.BytesIO(resp.content)) + if audio_np.ndim > 1: + audio_np = audio_np[:, 0] + return (sample_rate, audio_np.astype(np.float32)) + except Exception as e: + raise gr.Error(f"Failed to decode audio: {e}") + + +def generate_speech_stream(api_base, text, ref_audio, ref_audio_url, ref_text, response_format, chunk_seconds=0.5): + """Streaming: yield PCM chunks for progressive playback.""" + payload = build_payload(text, ref_audio, ref_audio_url, ref_text, response_format, stream=True) + + min_chunk_samples = int(PCM_SAMPLE_RATE * chunk_seconds) + prebuffer_chunks = 2 + pending: list[np.ndarray] = [] + pending_len = 0 + chunks_yielded = 0 + prebuffer: list[np.ndarray] = [] + + try: + leftover = b"" + with httpx.Client(timeout=300.0) as client: + with client.stream( + "POST", + f"{api_base}/v1/audio/speech", + json=payload, + headers={"Content-Type": "application/json", "Authorization": "Bearer EMPTY"}, + ) as resp: + if resp.status_code != 200: + resp.read() + raise gr.Error(f"Server error ({resp.status_code}): {resp.text}") + for chunk in resp.iter_bytes(): + if not chunk: + continue + raw = leftover + chunk + usable = len(raw) - (len(raw) % 2) + leftover = raw[usable:] + if usable == 0: + continue + float_samples = np.frombuffer(raw[:usable], dtype=np.int16).copy().astype(np.float32) / 32767.0 + pending.append(float_samples) + pending_len += len(float_samples) + if pending_len >= min_chunk_samples: + audio_chunk = np.concatenate(pending) + pending.clear() + pending_len = 0 + if chunks_yielded < prebuffer_chunks: + prebuffer.append(audio_chunk) + chunks_yielded += 1 + if chunks_yielded == prebuffer_chunks: + yield (PCM_SAMPLE_RATE, np.concatenate(prebuffer)) + prebuffer.clear() + else: + yield (PCM_SAMPLE_RATE, audio_chunk) + remaining = prebuffer + pending + if remaining: + yield (PCM_SAMPLE_RATE, np.concatenate(remaining)) + except httpx.TimeoutException: + raise gr.Error("Request timed out. The server may be busy.") + except httpx.ConnectError: + raise gr.Error(f"Cannot connect to server at {api_base}. Is the server running?") + + +def on_stream_change(stream: bool): + """Lock format to PCM when streaming.""" + if stream: + return gr.update(value="pcm", interactive=False) + return gr.update(value="wav", interactive=True) + + +def build_interface(api_base: str, stream_chunk_seconds: float = 0.5): + """Build the Gradio interface.""" + with gr.Blocks(title="Fish Speech S2 Pro Demo") as demo: + gr.Markdown("# Fish Speech S2 Pro - Text to Speech") + gr.Markdown(f"**Server:** `{api_base}` | **Model:** fishaudio/s2-pro | **Output:** 44.1kHz") + + with gr.Row(): + with gr.Column(scale=3): + text_input = gr.Textbox( + label="Text to Synthesize", + placeholder="Enter text here...", + lines=4, + ) + + with gr.Accordion("Voice Cloning (optional)", open=False): + gr.Markdown( + "Upload or link a short reference audio (10-30s) and provide its transcript to clone the voice." + ) + ref_audio = gr.Audio( + label="Reference Audio", + type="numpy", + sources=["upload", "microphone"], + ) + ref_audio_url = gr.Textbox( + label="Reference Audio URL (alternative to upload)", + placeholder="https://example.com/reference.wav", + lines=1, + ) + ref_text = gr.Textbox( + label="Reference Audio Transcript (required for cloning)", + placeholder="Exact transcript of the reference audio...", + lines=2, + ) + + with gr.Row(): + response_format = gr.Dropdown( + choices=["wav", "mp3", "flac", "pcm"], + value="wav", + label="Audio Format", + scale=1, + ) + stream_checkbox = gr.Checkbox( + label="Stream output", + value=False, + info="Progressive PCM streaming", + scale=1, + ) + + generate_btn = gr.Button("Generate Speech", variant="primary", size="lg") + + with gr.Column(scale=2): + audio_output = gr.Audio( + label="Generated Audio", + interactive=False, + streaming=True, + autoplay=True, + ) + gr.Markdown( + "### About\n" + "- **Fish Speech S2 Pro** by FishAudio: 4B dual-AR model\n" + "- **Voice cloning**: upload 10-30s reference + transcript\n" + "- **Streaming**: real-time PCM output\n" + "- **44.1kHz** output via DAC codec" + ) + + stream_checkbox.change(fn=on_stream_change, inputs=[stream_checkbox], outputs=[response_format]) + + all_inputs = [text_input, ref_audio, ref_audio_url, ref_text, response_format] + + def dispatch(stream_enabled, *args): + if stream_enabled: + yield from generate_speech_stream(api_base, *args, chunk_seconds=stream_chunk_seconds) + else: + yield generate_speech(api_base, *args) + + generate_btn.click(fn=dispatch, inputs=[stream_checkbox] + all_inputs, outputs=[audio_output]) + demo.queue() + return demo + + +def main(): + parser = argparse.ArgumentParser(description="Gradio demo for Fish Speech S2 Pro") + parser.add_argument("--api-base", default=DEFAULT_API_BASE, help=f"API base URL (default: {DEFAULT_API_BASE})") + parser.add_argument("--host", default="0.0.0.0", help="Gradio host (default: 0.0.0.0)") + parser.add_argument("--port", type=int, default=7860, help="Gradio port (default: 7860)") + parser.add_argument("--share", action="store_true", help="Share publicly") + parser.add_argument("--stream-chunk-seconds", type=float, default=0.5, help="Seconds per streaming chunk") + args = parser.parse_args() + + print(f"Connecting to vLLM server at: {args.api_base}") + demo = build_interface(args.api_base, stream_chunk_seconds=args.stream_chunk_seconds) + demo.launch(server_name=args.host, server_port=args.port, share=args.share) + + +if __name__ == "__main__": + main() diff --git a/examples/online_serving/fish_speech/run_gradio_demo.sh b/examples/online_serving/fish_speech/run_gradio_demo.sh new file mode 100755 index 000000000..98a696644 --- /dev/null +++ b/examples/online_serving/fish_speech/run_gradio_demo.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Launch Fish Speech S2 Pro server + Gradio demo together. +# +# Usage: +# ./run_gradio_demo.sh +# CUDA_VISIBLE_DEVICES=0 PORT=8091 GRADIO_PORT=7860 ./run_gradio_demo.sh + +set -e + +MODEL="${MODEL:-fishaudio/s2-pro}" +PORT="${PORT:-8091}" +GRADIO_PORT="${GRADIO_PORT:-7860}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +echo "Starting Fish Speech S2 Pro server (port $PORT)..." +FLASHINFER_DISABLE_VERSION_CHECK=1 \ +vllm-omni serve "$MODEL" \ + --stage-configs-path "$REPO_ROOT/vllm_omni/model_executor/stage_configs/fish_speech_s2_pro.yaml" \ + --host 0.0.0.0 \ + --port "$PORT" \ + --gpu-memory-utilization 0.9 \ + --trust-remote-code \ + --enforce-eager \ + --omni & +SERVER_PID=$! + +cleanup() { + echo "Stopping server (PID $SERVER_PID)..." + kill $SERVER_PID 2>/dev/null + wait $SERVER_PID 2>/dev/null +} +trap cleanup EXIT + +# Wait for server to be ready. +echo "Waiting for server to start..." +for i in $(seq 1 120); do + if curl -s "http://localhost:$PORT/health" > /dev/null 2>&1; then + echo "Server ready." + break + fi + sleep 2 +done + +echo "Starting Gradio demo (port $GRADIO_PORT)..." +python "$SCRIPT_DIR/gradio_demo.py" \ + --api-base "http://localhost:$PORT" \ + --port "$GRADIO_PORT" diff --git a/examples/online_serving/fish_speech/run_server.sh b/examples/online_serving/fish_speech/run_server.sh new file mode 100755 index 000000000..59c09c7fe --- /dev/null +++ b/examples/online_serving/fish_speech/run_server.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Launch vLLM-Omni server for Fish Speech S2 Pro +# +# Usage: +# ./run_server.sh +# CUDA_VISIBLE_DEVICES=0 ./run_server.sh + +set -e + +MODEL="${MODEL:-fishaudio/s2-pro}" +PORT="${PORT:-8091}" + +echo "Starting Fish Speech S2 Pro server with model: $MODEL" + +FLASHINFER_DISABLE_VERSION_CHECK=1 \ +vllm-omni serve "$MODEL" \ + --stage-configs-path vllm_omni/model_executor/stage_configs/fish_speech_s2_pro.yaml \ + --host 0.0.0.0 \ + --port "$PORT" \ + --gpu-memory-utilization 0.9 \ + --trust-remote-code \ + --enforce-eager \ + --omni diff --git a/examples/online_serving/fish_speech/speech_client.py b/examples/online_serving/fish_speech/speech_client.py new file mode 100644 index 000000000..e3ace37e5 --- /dev/null +++ b/examples/online_serving/fish_speech/speech_client.py @@ -0,0 +1,129 @@ +"""Client for Fish Speech S2 Pro via /v1/audio/speech endpoint. + +Examples: + # Basic TTS + python speech_client.py --text "Hello, how are you?" + + # Voice cloning + python speech_client.py --text "Hello, how are you?" \ + --ref-audio ref.wav --ref-text "This is the reference transcript." + + # Streaming PCM output + python speech_client.py --text "Hello world" --stream --output output.pcm +""" + +import argparse +import base64 +import os + +import httpx + +DEFAULT_API_BASE = "http://localhost:8091" +DEFAULT_API_KEY = "EMPTY" + + +def encode_audio_to_base64(audio_path: str) -> str: + """Encode a local audio file to base64 data URL.""" + if not os.path.exists(audio_path): + raise FileNotFoundError(f"Audio file not found: {audio_path}") + ext = audio_path.lower().rsplit(".", 1)[-1] + mime_map = {"wav": "audio/wav", "mp3": "audio/mpeg", "flac": "audio/flac", "ogg": "audio/ogg"} + mime_type = mime_map.get(ext, "audio/wav") + with open(audio_path, "rb") as f: + audio_b64 = base64.b64encode(f.read()).decode("utf-8") + return f"data:{mime_type};base64,{audio_b64}" + + +def run_tts(args) -> None: + """Generate speech via /v1/audio/speech API.""" + payload = { + "model": args.model, + "input": args.text, + "voice": "default", + "response_format": args.response_format, + } + + # Voice cloning parameters. + if args.ref_audio: + if args.ref_audio.startswith(("http://", "https://")): + payload["ref_audio"] = args.ref_audio + else: + payload["ref_audio"] = encode_audio_to_base64(args.ref_audio) + if args.ref_text: + payload["ref_text"] = args.ref_text + + if args.stream: + payload["stream"] = True + payload["response_format"] = "pcm" + + print(f"Model: {args.model}") + print(f"Text: {args.text}") + if args.ref_audio: + print(f"Voice cloning: ref_audio={args.ref_audio}, ref_text={args.ref_text}") + print("Generating audio...") + + api_url = f"{args.api_base}/v1/audio/speech" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {args.api_key}", + } + + if args.stream: + output_path = args.output or "output.pcm" + with httpx.Client(timeout=300.0) as client: + with client.stream("POST", api_url, json=payload, headers=headers) as resp: + if resp.status_code != 200: + print(f"Error: {resp.status_code}") + print(resp.read().decode()) + return + total_bytes = 0 + with open(output_path, "wb") as f: + for chunk in resp.iter_bytes(): + f.write(chunk) + total_bytes += len(chunk) + print(f"Streamed {total_bytes} bytes to: {output_path}") + else: + with httpx.Client(timeout=300.0) as client: + response = client.post(api_url, json=payload, headers=headers) + + if response.status_code != 200: + print(f"Error: {response.status_code}") + print(response.text) + return + + try: + text = response.content.decode("utf-8") + if text.startswith('{"error"'): + print(f"Error: {text}") + return + except UnicodeDecodeError: + pass + + output_path = args.output or "output.wav" + with open(output_path, "wb") as f: + f.write(response.content) + print(f"Audio saved to: {output_path}") + + +def main(): + parser = argparse.ArgumentParser(description="Fish Speech S2 Pro TTS client") + parser.add_argument("--api-base", default=DEFAULT_API_BASE, help="API base URL") + parser.add_argument("--api-key", default=DEFAULT_API_KEY, help="API key") + parser.add_argument("--model", "-m", default="fishaudio/s2-pro", help="Model name") + parser.add_argument("--text", required=True, help="Text to synthesize") + parser.add_argument("--ref-audio", default=None, help="Reference audio for voice cloning (path or URL)") + parser.add_argument("--ref-text", default=None, help="Transcript of reference audio") + parser.add_argument("--stream", action="store_true", help="Enable streaming (PCM output)") + parser.add_argument( + "--response-format", + default="wav", + choices=["wav", "mp3", "flac", "pcm", "aac", "opus"], + help="Audio format (default: wav)", + ) + parser.add_argument("--output", "-o", default=None, help="Output file path") + args = parser.parse_args() + run_tts(args) + + +if __name__ == "__main__": + main() diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index 331adda8e..3b5d67542 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -29,8 +29,8 @@ from vllm_omni.outputs import OmniRequestOutput logger = init_logger(__name__) -# TTS Configuration (currently supports Qwen3-TTS) -_TTS_MODEL_STAGES: set[str] = {"qwen3_tts"} +# TTS Configuration +_TTS_MODEL_STAGES: set[str] = {"qwen3_tts", "fish_speech_slow_ar"} _TTS_LANGUAGES: set[str] = { "Auto", "Chinese", @@ -140,6 +140,10 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): # Find and cache the TTS stage (if any) during initialization self._tts_stage = self._find_tts_stage() self._is_tts = self._tts_stage is not None + self._is_fish_speech = ( + self._tts_stage is not None and getattr(self._tts_stage, "model_stage", None) == "fish_speech_slow_ar" + ) + self._fish_speech_tokenizer = None # Cache TTS configuration values (computed once, reused per request) self._max_instructions_length = self._compute_max_instructions_length() @@ -780,6 +784,79 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): return params + def _build_fish_speech_prompt( + self, + request: OpenAICreateSpeechRequest, + ref_audio_data: tuple[list[float], int] | None = None, + ) -> dict[str, Any]: + """Build prompt for Fish Speech S2 Pro. + + Without voice cloning: + <|im_start|>user\\n<|speaker:0|>{text}<|im_end|>\\n<|im_start|>assistant\\n<|voice|> + + With voice cloning (ref_audio + ref_text): + <|im_start|>system\\n<|speaker:0|>{ref_text}<|audio_start|>{semantic_tokens}<|audio_end|><|im_end|> + <|im_start|>user\\n<|speaker:0|>{text}<|im_end|>\\n<|im_start|>assistant\\n<|voice|> + """ + from transformers import AutoTokenizer + + if self._fish_speech_tokenizer is None: + model_name = self.engine_client.model_config.model + self._fish_speech_tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True) + + tokenizer = self._fish_speech_tokenizer + model_name = self.engine_client.model_config.model + + if ref_audio_data is not None and request.ref_text: + # Voice cloning: encode reference audio and build system message. + from vllm_omni.model_executor.models.fish_speech.dac_encoder import ( + encode_reference_audio, + ) + + wav_samples, sr = ref_audio_data + semantic_token_ids = encode_reference_audio(model_name, wav_samples, sr) + + # Build system message with ref text + audio tokens. + audio_start_id = tokenizer.encode("<|audio_start|>", add_special_tokens=False) + audio_end_id = tokenizer.encode("<|audio_end|>", add_special_tokens=False) + + # System content: <|speaker:0|>{ref_text}<|audio_start|>{codes}<|audio_end|> + prefix_text = f"<|speaker:0|>{request.ref_text}" + prefix_ids = tokenizer.encode(prefix_text, add_special_tokens=False) + system_content_ids = prefix_ids + audio_start_id + semantic_token_ids + audio_end_id + + # Manually build system turn: <|im_start|>system\n{content}<|im_end|>\n + im_start = tokenizer.encode("<|im_start|>", add_special_tokens=False) + im_end = tokenizer.encode("<|im_end|>", add_special_tokens=False) + system_tag = tokenizer.encode("system\n", add_special_tokens=False) + newline = tokenizer.encode("\n", add_special_tokens=False) + system_ids = im_start + system_tag + system_content_ids + im_end + newline + + # User turn via chat template. + user_text = f"<|speaker:0|>{request.input}" + user_messages = [{"role": "user", "content": user_text}] + user_ids = tokenizer.apply_chat_template(user_messages, tokenize=True, add_generation_prompt=True) + prompt_ids = system_ids + user_ids + else: + # No voice cloning: simple user message. + user_text = f"<|speaker:0|>{request.input}" + messages = [{"role": "user", "content": user_text}] + prompt_ids = tokenizer.apply_chat_template(messages, tokenize=True, add_generation_prompt=True) + + # Append <|voice|> token to signal voice generation. + voice_token_id = tokenizer.encode("<|voice|>", add_special_tokens=False) + prompt_ids = prompt_ids + voice_token_id + + additional_information: dict[str, Any] = { + "text": [request.input], + "max_new_tokens": [request.max_new_tokens or 4096], + } + + return { + "prompt_token_ids": prompt_ids, + "additional_information": additional_information, + } + async def _prepare_speech_generation( self, request: OpenAICreateSpeechRequest, @@ -787,7 +864,18 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): if self.engine_client.errored: raise self.engine_client.dead_error - if self._is_tts: + if self._is_fish_speech: + if not request.input or not request.input.strip(): + raise ValueError("Input text cannot be empty") + ref_audio_data = None + if request.ref_audio is not None: + if not request.ref_text or not request.ref_text.strip(): + raise ValueError("Voice cloning requires 'ref_text' (transcript of the reference audio)") + wav_list, sr = await self._resolve_ref_audio(request.ref_audio) + ref_audio_data = (wav_list, sr) + prompt = self._build_fish_speech_prompt(request, ref_audio_data=ref_audio_data) + tts_params = {} + elif self._is_tts: validation_error = self._validate_tts_request(request) if validation_error: raise ValueError(validation_error) @@ -804,14 +892,29 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): prompt = {"prompt": request.input} request_id = f"speech-{random_uuid()}" + model_type = ( + "fish_speech" + if self._is_fish_speech + else tts_params.get("task_type", ["unknown"])[0] + if self._is_tts + else "generic" + ) logger.info( - "TTS speech request %s: text=%r, task_type=%s", + "TTS speech request %s: text=%r, model=%s", request_id, request.input[:50] + "..." if len(request.input) > 50 else request.input, - tts_params.get("task_type", ["unknown"])[0], + model_type, ) sampling_params_list = self.engine_client.default_sampling_params_list + + # Override Stage-0 max_tokens if caller specified max_new_tokens (Fish Speech). + if self._is_fish_speech and request.max_new_tokens is not None and sampling_params_list: + import copy + + sampling_params_list = copy.deepcopy(sampling_params_list) + sampling_params_list[0].max_tokens = request.max_new_tokens + generator = self.engine_client.generate( prompt=prompt, request_id=request_id, diff --git a/vllm_omni/model_executor/models/fish_speech/__init__.py b/vllm_omni/model_executor/models/fish_speech/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/vllm_omni/model_executor/models/fish_speech/configuration_fish_speech.py b/vllm_omni/model_executor/models/fish_speech/configuration_fish_speech.py new file mode 100644 index 000000000..9ed4ed013 --- /dev/null +++ b/vllm_omni/model_executor/models/fish_speech/configuration_fish_speech.py @@ -0,0 +1,154 @@ +"""Configuration classes for Fish Speech S2 Pro (fish_qwen3_omni). + +The HuggingFace config uses field names from the original Fish Speech codebase +(dim, n_head, n_layer, etc.). This module re-exports them under the standard +Transformers/Qwen3 attribute names so that vLLM's ``Qwen3Model`` can consume +the Slow AR config transparently. +""" + +from __future__ import annotations + +from transformers import PretrainedConfig + + +class FishSpeechSlowARConfig(PretrainedConfig): + """Slow AR (text_model) config -- Qwen3-based transformer. + + Maps Fish Speech field names to Qwen3-compatible attribute names so + ``vllm.model_executor.models.qwen3.Qwen3Model`` works out of the box. + """ + + model_type = "fish_qwen3" + + def __init__( + self, + vocab_size: int = 155776, + dim: int = 2560, + n_head: int = 32, + n_local_heads: int = 8, + head_dim: int = 128, + n_layer: int = 36, + intermediate_size: int = 9728, + attention_qk_norm: bool = True, + rope_base: float = 1_000_000.0, + max_seq_len: int = 32768, + tie_word_embeddings: bool = True, + codebook_size: int = 4096, + num_codebooks: int = 10, + semantic_begin_id: int = 0, + semantic_end_id: int = 0, + scale_codebook_embeddings: bool = False, + rms_norm_eps: float = 1e-6, + **kwargs, + ): + # Fish Speech field names → standard Transformers/Qwen3 names. + self.hidden_size = dim + self.num_attention_heads = n_head + self.num_key_value_heads = n_local_heads + self.head_dim = head_dim + self.num_hidden_layers = n_layer + self.intermediate_size = intermediate_size + self.max_position_embeddings = max_seq_len + self.rope_theta = rope_base + self.rms_norm_eps = rms_norm_eps + self.hidden_act = "silu" + self.attention_bias = False + self.attention_qk_norm = attention_qk_norm + + # Fish Speech codec / codebook fields. + self.codebook_size = codebook_size + self.num_codebooks = num_codebooks + self.semantic_begin_id = semantic_begin_id + self.semantic_end_id = semantic_end_id + self.scale_codebook_embeddings = scale_codebook_embeddings + + super().__init__( + vocab_size=vocab_size, + tie_word_embeddings=tie_word_embeddings, + **kwargs, + ) + + +class FishSpeechFastARConfig(PretrainedConfig): + """Fast AR (audio_decoder) config -- 4-layer residual codebook predictor.""" + + model_type = "fish_qwen3_audio_decoder" + + def __init__( + self, + vocab_size: int = 4096, + num_codebooks: int = 10, + dim: int = 2560, + n_head: int = 32, + n_local_heads: int = 8, + head_dim: int = 128, + n_layer: int = 4, + intermediate_size: int = 9728, + max_seq_len: int = 11, + text_dim: int = 2560, + audio_hidden_dim: int = 5120, + attention_qk_norm: bool = False, + rope_base: float = 1_000_000.0, + rms_norm_eps: float = 1e-6, + **kwargs, + ): + self.hidden_size = dim + self.num_attention_heads = n_head + self.num_key_value_heads = n_local_heads + self.head_dim = head_dim + self.num_hidden_layers = n_layer + self.intermediate_size = intermediate_size + self.max_position_embeddings = max_seq_len + self.rope_theta = rope_base + self.rms_norm_eps = rms_norm_eps + self.hidden_act = "silu" + self.attention_bias = False + self.attention_qk_norm = attention_qk_norm + self.num_codebooks = num_codebooks + self.text_dim = text_dim + self.audio_hidden_dim = audio_hidden_dim + + super().__init__(vocab_size=vocab_size, **kwargs) + + +class FishSpeechConfig(PretrainedConfig): + """Top-level config for Fish Speech S2 Pro (fish_qwen3_omni). + + Wraps ``text_config`` (Slow AR) and ``audio_decoder_config`` (Fast AR). + """ + + model_type = "fish_qwen3_omni" + sub_configs = { + "text_config": FishSpeechSlowARConfig, + "audio_decoder_config": FishSpeechFastARConfig, + } + + def __init__( + self, + text_config: dict | FishSpeechSlowARConfig | None = None, + audio_decoder_config: dict | FishSpeechFastARConfig | None = None, + semantic_start_token_id: int = 151678, + semantic_end_token_id: int = 155773, + audio_pad_token_id: int = 151677, + **kwargs, + ): + if isinstance(text_config, dict): + text_config = FishSpeechSlowARConfig(**text_config) + if isinstance(audio_decoder_config, dict): + audio_decoder_config = FishSpeechFastARConfig(**audio_decoder_config) + + self.text_config = text_config or FishSpeechSlowARConfig() + self.audio_decoder_config = audio_decoder_config or FishSpeechFastARConfig() + + self.semantic_start_token_id = semantic_start_token_id + self.semantic_end_token_id = semantic_end_token_id + self.audio_pad_token_id = audio_pad_token_id + + # Propagate semantic IDs into text_config for convenience. + self.text_config.semantic_begin_id = semantic_start_token_id + self.text_config.semantic_end_id = semantic_end_token_id + + super().__init__(**kwargs) + + def get_text_config(self, **kwargs) -> FishSpeechSlowARConfig: + return self.text_config diff --git a/vllm_omni/model_executor/models/fish_speech/dac_encoder.py b/vllm_omni/model_executor/models/fish_speech/dac_encoder.py new file mode 100644 index 000000000..084a863ad --- /dev/null +++ b/vllm_omni/model_executor/models/fish_speech/dac_encoder.py @@ -0,0 +1,111 @@ +"""DAC codec encoder for Fish Speech S2 Pro voice cloning. + +Encodes reference audio into VQ codes for use as prompt conditioning. +Runs on CPU in the API server process -- loaded lazily on first use. +""" + +from __future__ import annotations + +import os + +import numpy as np +import torch +import torch.nn as nn +from vllm.logger import init_logger + +from vllm_omni.model_executor.models.fish_speech.dac_utils import ( + DAC_SAMPLE_RATE, + build_dac_codec, +) + +logger = init_logger(__name__) + +_codec_cache: dict[str, nn.Module] = {} + + +def _load_dac_codec(model_path: str) -> nn.Module: + """Load the DAC codec model from codec.pth (cached per model_path).""" + if model_path in _codec_cache: + return _codec_cache[model_path] + + codec_path = os.path.join(model_path, "codec.pth") + if not os.path.exists(codec_path): + from transformers.utils.hub import cached_file + + cached = cached_file(model_path, "codec.pth") + if cached is not None: + codec_path = cached + + if not os.path.exists(codec_path): + raise FileNotFoundError( + f"codec.pth not found for {model_path}. Required for voice cloning with Fish Speech S2 Pro." + ) + + codec = build_dac_codec() + + state_dict = torch.load(codec_path, map_location="cpu", weights_only=True) + if "generator" in state_dict: + state_dict = state_dict["generator"] + codec.load_state_dict(state_dict, strict=False) + codec.eval() + + _codec_cache[model_path] = codec + logger.info("Loaded DAC codec encoder from %s (CPU)", codec_path) + return codec + + +def _resample(wav: np.ndarray, sr: int, target_sr: int) -> np.ndarray: + """Resample audio using torchaudio's polyphase resampling.""" + if sr == target_sr: + return wav + import torchaudio + + wav_t = torch.from_numpy(wav).unsqueeze(0).float() + wav_t = torchaudio.functional.resample(wav_t, sr, target_sr) + return wav_t.squeeze(0).numpy() + + +@torch.no_grad() +def encode_reference_audio( + model_path: str, + wav_samples: list[float] | np.ndarray, + sample_rate: int, +) -> list[int]: + """Encode reference audio into semantic token IDs for prompt conditioning. + + Args: + model_path: HuggingFace model path (for locating codec.pth). + wav_samples: Audio waveform samples (mono, float). + sample_rate: Sample rate of the input audio. + + Returns: + List of semantic token IDs (151678 + code_value for each frame). + """ + codec = _load_dac_codec(model_path) + + wav = np.asarray(wav_samples, dtype=np.float32) + if wav.ndim > 1: + wav = np.mean(wav, axis=-1) + + # Resample to DAC sample rate (44100). + wav = _resample(wav, sample_rate, DAC_SAMPLE_RATE) + + # Encode: [1, 1, T] -> codes [1, num_codebooks, num_frames] + wav_tensor = torch.from_numpy(wav).unsqueeze(0).unsqueeze(0).float() + feature_lengths = torch.tensor([wav_tensor.shape[-1]]) + codes, feature_lengths_out = codec.encode(wav_tensor, feature_lengths) + + # Extract semantic codebook (index 0) - shape [num_frames]. + semantic_codes = codes[0, 0, :].tolist() + + # Convert to semantic token IDs: <|semantic:{i}|> = 151678 + i + SEMANTIC_TOKEN_OFFSET = 151678 + semantic_token_ids = [SEMANTIC_TOKEN_OFFSET + int(c) for c in semantic_codes] + + logger.info( + "Encoded reference audio: %d samples @ %dHz -> %d semantic tokens", + len(wav_samples), + sample_rate, + len(semantic_token_ids), + ) + return semantic_token_ids diff --git a/vllm_omni/model_executor/models/fish_speech/dac_utils.py b/vllm_omni/model_executor/models/fish_speech/dac_utils.py new file mode 100644 index 000000000..f1f2590a3 --- /dev/null +++ b/vllm_omni/model_executor/models/fish_speech/dac_utils.py @@ -0,0 +1,92 @@ +"""Shared DAC codec construction for Fish Speech S2 Pro. + +Used by both the encoder (voice cloning, CPU) and the decoder (Stage 1, GPU). +""" + +from __future__ import annotations + +import torch.nn as nn + +# Default DAC codec config matching s2-pro's modded_dac_vq.yaml. +DAC_SAMPLE_RATE = 44100 +DAC_HOP_LENGTH = 2048 # 512 (decoder upsample) * 4 (quantizer upsample) +DAC_NUM_CODEBOOKS = 10 # 1 semantic + 9 residual + + +def build_dac_codec() -> nn.Module: + """Construct a DAC codec model (uninitialized weights). + + Returns the model on CPU in eval mode -- caller is responsible for + loading weights and moving to the target device. + """ + from fish_speech.models.dac.modded_dac import ( + DAC, + ModelArgs, + WindowLimitedTransformer, + ) + from fish_speech.models.dac.rvq import DownsampleResidualVectorQuantize + + base_transformer_kwargs = dict( + block_size=16384, + n_local_heads=-1, + head_dim=64, + rope_base=10000, + norm_eps=1e-5, + dropout_rate=0.0, + attn_dropout_rate=0.0, + channels_first=True, + ) + + def _make_transformer_config(**kw): + return ModelArgs(**{**base_transformer_kwargs, **kw}) + + quantizer_transformer_config = ModelArgs( + block_size=4096, + n_layer=8, + n_head=16, + dim=1024, + intermediate_size=3072, + n_local_heads=-1, + head_dim=64, + rope_base=10000, + norm_eps=1e-5, + dropout_rate=0.0, + attn_dropout_rate=0.0, + channels_first=True, + ) + post_module = WindowLimitedTransformer( + causal=True, + window_size=128, + input_dim=1024, + config=quantizer_transformer_config, + ) + pre_module = WindowLimitedTransformer( + causal=True, + window_size=128, + input_dim=1024, + config=quantizer_transformer_config, + ) + quantizer = DownsampleResidualVectorQuantize( + input_dim=1024, + n_codebooks=9, + codebook_size=1024, + codebook_dim=8, + quantizer_dropout=0.0, + downsample_factor=[2, 2], + post_module=post_module, + pre_module=pre_module, + semantic_codebook_size=4096, + ) + codec = DAC( + encoder_dim=64, + encoder_rates=[2, 4, 8, 8], + decoder_dim=1536, + decoder_rates=[8, 8, 4, 2], + quantizer=quantizer, + sample_rate=DAC_SAMPLE_RATE, + causal=True, + encoder_transformer_layers=[0, 0, 0, 4], + decoder_transformer_layers=[4, 0, 0, 0], + transformer_general_config=_make_transformer_config, + ) + return codec diff --git a/vllm_omni/model_executor/models/fish_speech/fish_speech_dac_decoder.py b/vllm_omni/model_executor/models/fish_speech/fish_speech_dac_decoder.py new file mode 100644 index 000000000..a889f66fd --- /dev/null +++ b/vllm_omni/model_executor/models/fish_speech/fish_speech_dac_decoder.py @@ -0,0 +1,272 @@ +"""Fish Speech S2 Pro -- DAC Decoder (Stage 1). + +Loads the DAC codec from ``codec.pth`` and decodes codebook indices +[num_codebooks, T] → audio waveform at 44.1 kHz. + +Analogous to ``Qwen3TTSCode2Wav`` in qwen3_tts. + +Requires the ``fish-speech`` package for the DAC model architecture. +Install with: ``pip install fish-speech`` +""" + +from __future__ import annotations + +import os +from collections.abc import Iterable +from typing import Any + +import torch +import torch.nn as nn +from vllm.config import VllmConfig +from vllm.forward_context import get_forward_context, is_forward_context_available +from vllm.logger import init_logger + +from vllm_omni.model_executor.models.fish_speech.dac_utils import ( + DAC_HOP_LENGTH, + DAC_NUM_CODEBOOKS, + DAC_SAMPLE_RATE, + build_dac_codec, +) +from vllm_omni.model_executor.models.output_templates import OmniOutput + +logger = init_logger(__name__) + + +class FishSpeechDACDecoder(nn.Module): + """Stage-1 DAC decoder for Fish Speech S2 Pro (GenerationModelRunner). + + Consumes frame-aligned codec tokens from input_ids and decodes waveform + via the DAC codec decoder. + """ + + input_modalities = "audio" + + def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""): + super().__init__() + self.vllm_config = vllm_config + self.model_path = vllm_config.model_config.model + + self.have_multimodal_outputs = True + self.has_preprocess = False + self.has_postprocess = False + self.enable_update_additional_information = True + self.requires_raw_input_tokens = True + + self._codec: nn.Module | None = None + self._num_codebooks: int = DAC_NUM_CODEBOOKS + self._output_sample_rate: int = DAC_SAMPLE_RATE + self._hop_length: int = DAC_HOP_LENGTH + self._logged_codec_stats = False + + def _ensure_codec_loaded(self) -> None: + if self._codec is not None: + return + + codec_path = os.path.join(self.model_path, "codec.pth") + if not os.path.exists(codec_path): + # Try HuggingFace cache. + try: + from transformers.utils.hub import cached_file + + cached = cached_file(self.model_path, "codec.pth") + if cached is not None: + codec_path = cached + except Exception: + pass + + if not os.path.exists(codec_path): + raise FileNotFoundError( + f"codec.pth not found at {codec_path}. Make sure the Fish Speech S2 Pro model includes codec.pth." + ) + + codec = build_dac_codec() + + # Load weights. + state_dict = torch.load(codec_path, map_location="cpu", weights_only=True) + # Some checkpoints wrap under "generator" key. + if "generator" in state_dict: + state_dict = state_dict["generator"] + codec.load_state_dict(state_dict, strict=False) + + device = self.vllm_config.device_config.device + codec = codec.to(device=device, dtype=torch.float32) + codec.eval() + self._codec = codec + + logger.info( + "Fish Speech DAC codec loaded from %s (device=%s, sample_rate=%d)", + codec_path, + device, + self._output_sample_rate, + ) + + def embed_input_ids(self, input_ids: torch.Tensor, **_: Any) -> torch.Tensor: + if input_ids.numel() == 0: + return torch.empty((0, 1), device=input_ids.device, dtype=torch.float32) + return torch.zeros((input_ids.shape[0], 1), device=input_ids.device, dtype=torch.float32) + + def compute_logits(self, hidden_states: torch.Tensor | OmniOutput, sampling_metadata: Any = None) -> None: + return None + + def _split_request_ids( + self, + ids: torch.Tensor, + seq_token_counts: list[int] | None = None, + ) -> list[torch.Tensor]: + if seq_token_counts is not None and len(seq_token_counts) > 1: + boundaries = [0] + for count in seq_token_counts: + boundaries.append(boundaries[-1] + count) + n = ids.numel() + return [ids[boundaries[i] : min(boundaries[i + 1], n)] for i in range(len(seq_token_counts))] + if is_forward_context_available(): + slices = get_forward_context().ubatch_slices + if slices is not None and len(slices) > 1 and not any(hasattr(s, "token_slice") for s in slices): + boundaries = [0] + for s in slices: + boundaries.append(boundaries[-1] + s) + return [ids[boundaries[i] : boundaries[i + 1]] for i in range(len(boundaries) - 1)] + return [ids] + + @torch.no_grad() + def forward( + self, + input_ids: torch.Tensor | None = None, + positions: torch.Tensor | None = None, + intermediate_tensors: Any = None, + inputs_embeds: torch.Tensor | None = None, + runtime_additional_information: list[dict[str, Any]] | None = None, + **kwargs: Any, + ) -> OmniOutput: + """Decode codec codes into audio waveform. + + input_ids layout per request: flat codes [num_codebooks * num_frames]. + Codes are codebook-major: [cb0_f0, cb0_f1, ..., cb0_fN, cb1_f0, ...]. + """ + self._ensure_codec_loaded() + assert self._codec is not None + + q = self._num_codebooks + sr_val = self._output_sample_rate + sr_tensor = torch.tensor(sr_val, dtype=torch.int32) + empty = torch.zeros((0,), dtype=torch.float32) + + if input_ids is None or input_ids.numel() == 0: + return OmniOutput( + text_hidden_states=None, + multimodal_outputs={"model_outputs": [empty], "sr": [sr_tensor]}, + ) + + ids = input_ids.reshape(-1).to(dtype=torch.long) + request_ids_list = self._split_request_ids(ids, kwargs.get("seq_token_counts")) + + parsed: list[tuple[int, int]] = [] + valid_codes_qf: list[torch.Tensor] = [] + valid_indices: list[int] = [] + left_context_size = [0] * len(request_ids_list) + if runtime_additional_information is not None: + for i, info in enumerate(runtime_additional_information): + if i >= len(left_context_size): + break + if "left_context_size" in info: + left_context_size[i] = info["left_context_size"] + + for i, req_ids in enumerate(request_ids_list): + if req_ids.numel() < 1: + parsed.append((0, 0)) + continue + ctx_frames = left_context_size[i] + flat = req_ids + n = flat.numel() + if n == 0 or n % q != 0: + if n > 0: + logger.warning( + "DAC decoder input_ids length %d not divisible by num_codebooks %d; returning empty audio.", + n, + q, + ) + parsed.append((0, 0)) + continue + frames = n // q + codes_qf = flat.reshape(q, frames) + parsed.append((ctx_frames, frames)) + valid_codes_qf.append(codes_qf) + valid_indices.append(i) + + num_req = len(request_ids_list) + if not valid_codes_qf: + return OmniOutput( + text_hidden_states=None, + multimodal_outputs={ + "model_outputs": [empty] * num_req, + "sr": [sr_tensor] * num_req, + }, + ) + + if not self._logged_codec_stats: + self._logged_codec_stats = True + try: + c = valid_codes_qf[0] + logger.info( + "DAC decoder: frames=%d q=%d uniq=%d range=[%d,%d] batch=%d", + c.shape[1], + q, + int(torch.unique(c).numel()), + int(c.min().item()), + int(c.max().item()), + len(valid_codes_qf), + ) + except Exception: + pass + + # Decode each request individually. + wav_tensors: list[torch.Tensor] = [] + for codes_qf in valid_codes_qf: + codes_bqf = codes_qf.unsqueeze(0) # [1, num_codebooks, num_frames] + num_frames = codes_qf.shape[1] + feature_lengths = torch.tensor([num_frames], device=codes_bqf.device) + with torch.cuda.amp.autocast(enabled=False): + wav, audio_lengths = self._codec.decode(codes_bqf, feature_lengths) + # wav shape: [1, 1, wav_len] + wav_tensors.append(wav.squeeze(0).squeeze(0)) # [wav_len] + + audios: list[torch.Tensor] = [empty] * num_req + srs = [sr_tensor] * num_req + + for j, idx in enumerate(valid_indices): + ctx_frames, actual_frames = parsed[idx] + wav = wav_tensors[j] + # Trim context frames (left overlap for streaming). + if ctx_frames > 0: + cut = ctx_frames * self._hop_length + if cut < wav.shape[0]: + wav = wav[cut:] + else: + logger.warning( + "Context trim %d >= decoded length %d; returning empty audio.", + cut, + wav.shape[0], + ) + continue + if wav.shape[0] > 0: + audios[idx] = wav.to(dtype=torch.float32).reshape(-1) + + return OmniOutput( + text_hidden_states=None, + multimodal_outputs={"model_outputs": audios, "sr": srs}, + ) + + def make_omni_output(self, model_outputs: torch.Tensor | OmniOutput, **kwargs: Any) -> OmniOutput: + if isinstance(model_outputs, OmniOutput): + return model_outputs + if not (isinstance(model_outputs, tuple) and len(model_outputs) == 2): + raise TypeError(f"FishSpeechDACDecoder expected (audio_tensor, sr), got {type(model_outputs)}") + audio_tensor, sr = model_outputs + return OmniOutput( + text_hidden_states=None, + multimodal_outputs={"model_outputs": audio_tensor, "sr": sr}, + ) + + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: + # DAC codec weights are loaded lazily from codec.pth, not from the main checkpoint. + return set() diff --git a/vllm_omni/model_executor/models/fish_speech/fish_speech_fast_ar.py b/vllm_omni/model_executor/models/fish_speech/fish_speech_fast_ar.py new file mode 100644 index 000000000..f73914c28 --- /dev/null +++ b/vllm_omni/model_executor/models/fish_speech/fish_speech_fast_ar.py @@ -0,0 +1,470 @@ +"""Fast AR (audio_decoder) for Fish Speech S2 Pro. + +4-layer transformer that predicts residual codebook codes (1..num_codebooks-1) +autoregressively after each Slow AR step. Analogous to Qwen3 TTS's +CodePredictor but with its own embedding table, RoPE, and output head. + +Uses re-prefill (no KV cache): each AR step forwards the full growing +sequence through the 4-layer transformer. This trades ~O(T^2) attention +FLOPs (negligible for T=10, 4 layers) for zero KV cache management. + +Optimisations: + - torch.compile on model forward (kernel fusion) + - Pre-allocated projection / embedding buffer + - Pre-allocated position_ids + - Inline sampling +""" + +from __future__ import annotations + +from collections.abc import Iterable + +import torch +import torch.nn as nn +import torch.nn.functional as F +from vllm.config import VllmConfig +from vllm.config.vllm import set_current_vllm_config +from vllm.logger import init_logger +from vllm.model_executor.layers.layernorm import RMSNorm +from vllm.model_executor.layers.linear import ( + MergedColumnParallelLinear, + QKVParallelLinear, + RowParallelLinear, +) +from vllm.model_executor.layers.rotary_embedding import get_rope +from vllm.model_executor.model_loader.weight_utils import ( + default_weight_loader, + maybe_remap_kv_scale_name, +) +from vllm.model_executor.models.utils import is_pp_missing_parameter + +from .configuration_fish_speech import FishSpeechFastARConfig, FishSpeechSlowARConfig + +logger = init_logger(__name__) + + +# =================================================================== +# Standalone Fast AR Layers (no vLLM paged attention) +# =================================================================== + + +class _FastARAttention(nn.Module): + """Multi-head attention using F.scaled_dot_product_attention (SDPA). + + Supports fused QKV, RoPE, optional q/k normalization, and native GQA. + Input: [B, seq_len, hidden_size]. + """ + + def __init__(self, config: FishSpeechFastARConfig, *, prefix: str = "") -> None: + super().__init__() + self.hidden_size = config.hidden_size + self.num_heads = config.num_attention_heads + self.num_kv_heads = config.num_key_value_heads + self.head_dim = config.head_dim + self.q_size = self.num_heads * self.head_dim + self.kv_size = self.num_kv_heads * self.head_dim + self.scaling = self.head_dim**-0.5 + self._use_gqa = self.num_kv_heads != self.num_heads + + self.qkv_proj = QKVParallelLinear( + hidden_size=self.hidden_size, + head_size=self.head_dim, + total_num_heads=self.num_heads, + total_num_kv_heads=self.num_kv_heads, + bias=False, + prefix=f"{prefix}.qkv_proj", + disable_tp=True, + ) + self.o_proj = RowParallelLinear( + input_size=self.num_heads * self.head_dim, + output_size=self.hidden_size, + bias=False, + prefix=f"{prefix}.o_proj", + disable_tp=True, + ) + self.rotary_emb = get_rope( + self.head_dim, + max_position=config.max_position_embeddings, + is_neox_style=False, # Fish Speech uses interleaved (GPT-J) RoPE + rope_parameters={"rope_theta": config.rope_theta, "rope_type": "default"}, + ) + # Fish Speech Fast AR typically has attention_qk_norm=false. + if config.attention_qk_norm: + self.q_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps) + self.k_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps) + else: + self.q_norm = None + self.k_norm = None + + def forward(self, hidden_states: torch.Tensor, position_ids: torch.Tensor) -> torch.Tensor: + bsz, seq_len, _ = hidden_states.shape + + qkv, _ = self.qkv_proj(hidden_states.reshape(bsz * seq_len, -1)) + q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1) + + if self.q_norm is not None: + q = self.q_norm(q.view(-1, self.num_heads, self.head_dim)).view(q.shape) + k = self.k_norm(k.view(-1, self.num_kv_heads, self.head_dim)).view(k.shape) + + q, k = self.rotary_emb(position_ids, q, k) + + q = q.view(bsz, seq_len, self.num_heads, self.head_dim).transpose(1, 2) + k = k.view(bsz, seq_len, self.num_kv_heads, self.head_dim).transpose(1, 2) + v = v.view(bsz, seq_len, self.num_kv_heads, self.head_dim).transpose(1, 2) + + attn_out = F.scaled_dot_product_attention( + q, + k, + v, + scale=self.scaling, + is_causal=True, + enable_gqa=self._use_gqa, + ) + attn_out = attn_out.transpose(1, 2).reshape(bsz * seq_len, -1) + output, _ = self.o_proj(attn_out) + return output.view(bsz, seq_len, -1) + + +class _FastARMLP(nn.Module): + """SiLU-gated MLP, matching Qwen3/LLaMA MLP structure.""" + + def __init__(self, config: FishSpeechFastARConfig, *, prefix: str = "") -> None: + super().__init__() + self.gate_up_proj = MergedColumnParallelLinear( + input_size=config.hidden_size, + output_sizes=[config.intermediate_size] * 2, + bias=False, + prefix=f"{prefix}.gate_up_proj", + disable_tp=True, + ) + self.down_proj = RowParallelLinear( + input_size=config.intermediate_size, + output_size=config.hidden_size, + bias=False, + prefix=f"{prefix}.down_proj", + disable_tp=True, + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + gate_up, _ = self.gate_up_proj(x) + gate, up = gate_up.chunk(2, dim=-1) + x = F.silu(gate) * up + x, _ = self.down_proj(x) + return x + + +class _FastARDecoderLayer(nn.Module): + """Transformer decoder layer for Fast AR (SDPA, no KV cache).""" + + def __init__(self, config: FishSpeechFastARConfig, *, prefix: str = "") -> None: + super().__init__() + self.self_attn = _FastARAttention(config, prefix=f"{prefix}.self_attn") + self.mlp = _FastARMLP(config, prefix=f"{prefix}.mlp") + self.input_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps) + self.post_attention_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps) + + def forward(self, hidden_states: torch.Tensor, position_ids: torch.Tensor) -> torch.Tensor: + residual = hidden_states + hidden_states = self.input_layernorm(hidden_states) + hidden_states = self.self_attn(hidden_states, position_ids) + hidden_states = residual + hidden_states + + residual = hidden_states + hidden_states = self.post_attention_layernorm(hidden_states) + hidden_states = self.mlp(hidden_states) + hidden_states = residual + hidden_states + return hidden_states + + +# =================================================================== +# Fast AR Transformer Model +# =================================================================== + + +class FishSpeechFastARModel(nn.Module): + """4-layer transformer for residual codebook prediction (re-prefill).""" + + def __init__(self, config: FishSpeechFastARConfig, *, prefix: str = "") -> None: + super().__init__() + self.config = config + self.layers = nn.ModuleList( + [_FastARDecoderLayer(config, prefix=f"{prefix}.layers.{i}") for i in range(config.num_hidden_layers)] + ) + # NOTE: final norm is handled by FishSpeechFastAR.fast_norm (one norm weight + # in checkpoint: audio_decoder.norm.weight → fast_ar.fast_norm.weight). + + def forward(self, inputs_embeds: torch.Tensor, position_ids: torch.Tensor) -> torch.Tensor: + hidden_states = inputs_embeds + for layer in self.layers: + hidden_states = layer(hidden_states, position_ids) + return hidden_states + + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: + stacked_params_mapping = [ + ("qkv_proj", "q_proj", "q"), + ("qkv_proj", "k_proj", "k"), + ("qkv_proj", "v_proj", "v"), + ("gate_up_proj", "gate_proj", 0), + ("gate_up_proj", "up_proj", 1), + ] + params_dict = dict(self.named_parameters(remove_duplicate=False)) + loaded_params: set[str] = set() + for name, loaded_weight in weights: + if "rotary_emb.inv_freq" in name: + continue + for param_name, weight_name, shard_id in stacked_params_mapping: + if weight_name not in name: + continue + mapped = name.replace(weight_name, param_name) + if mapped.endswith(".bias") and mapped not in params_dict: + continue + if is_pp_missing_parameter(mapped, self): + continue + if mapped.endswith("scale"): + mapped = maybe_remap_kv_scale_name(mapped, params_dict) + if mapped is None: + continue + param = params_dict.get(mapped) + if param is None: + continue + weight_loader = getattr(param, "weight_loader", default_weight_loader) + if weight_loader == default_weight_loader: + weight_loader(param, loaded_weight) + else: + weight_loader(param, loaded_weight, shard_id) + loaded_params.add(mapped) + break + else: + mapped = maybe_remap_kv_scale_name(name, params_dict) + if mapped is None: + continue + if name.endswith(".bias") and mapped not in params_dict: + continue + if is_pp_missing_parameter(mapped, self): + continue + param = params_dict.get(mapped) + if param is None: + continue + weight_loader = getattr(param, "weight_loader", default_weight_loader) + weight_loader(param, loaded_weight) + loaded_params.add(mapped) + return loaded_params + + +# =================================================================== +# Fast AR Wrapper (optimised re-prefill + torch.compile) +# =================================================================== + + +class FishSpeechFastAR(nn.Module): + """vLLM-native Fast AR for Fish Speech (residual codebooks). + + Re-prefill approach: each AR step forwards the full growing sequence + through the 4-layer transformer. No KV cache needed. + + Optimisations over baseline: + 1. torch.compile on model forward -- kernel fusion. + 2. Pre-allocated embedding buffer [B, max_seq, H]. + 3. Pre-allocated position_ids. + 4. Inline sampling. + """ + + def __init__( + self, + *, + vllm_config: VllmConfig, + config: FishSpeechFastARConfig, + slow_ar_config: FishSpeechSlowARConfig, + prefix: str = "fast_ar", + ) -> None: + super().__init__() + self._vllm_config = vllm_config + self.config = config + self.slow_ar_config = slow_ar_config + + self.model = FishSpeechFastARModel(config, prefix=f"{prefix}.model") + + # Codebook embeddings for Fast AR (separate from Slow AR's codebook_embeddings). + self.fast_embeddings = nn.Embedding(config.vocab_size, config.hidden_size) + + # Output projection for residual codes. + self.fast_output = nn.Linear(config.hidden_size, config.vocab_size, bias=False) + + # Layer norm for Fast AR output. + self.fast_norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps) + + # Projection from Slow AR hidden dim → Fast AR hidden dim. + # s2-pro has same dim for both (2560) and no fast_project_in in checkpoint, + # so use identity when dims match. + if slow_ar_config.hidden_size != config.hidden_size: + self.fast_project_in = nn.Linear(slow_ar_config.hidden_size, config.hidden_size, bias=True) + else: + self.fast_project_in = nn.Identity() + + self._num_codebooks = config.num_codebooks + self._fast_dim = config.hidden_size + + # Pre-allocated buffers (lazily initialised on first forward). + self._embed_buf: torch.Tensor | None = None + self._pos_ids: torch.Tensor | None = None + self._compiled_model_fwd: object | None = None + + def _ensure_buffers(self, bsz: int, device: torch.device, dtype: torch.dtype) -> None: + max_seq = self._num_codebooks + 1 # hidden_state + num_codebooks codes + if ( + self._embed_buf is not None + and self._embed_buf.shape[0] >= bsz + and self._embed_buf.device == device + and self._embed_buf.dtype == dtype + ): + return + self._embed_buf = torch.zeros(bsz, max_seq, self._fast_dim, dtype=dtype, device=device) + self._pos_ids = torch.arange(max_seq, dtype=torch.long, device=device) + + def _setup_compile(self) -> None: + if self._compiled_model_fwd is not None: + return + # TODO: Enable torch.compile for performance. Eager for now to avoid + # potential graph-break issues during initial bring-up. + self._compiled_model_fwd = self.model.forward + + @torch.inference_mode() + def forward( + self, + slow_ar_hidden: torch.Tensor, + semantic_token_id: torch.Tensor, + *, + do_sample: bool = True, + temperature: float = 0.8, + top_k: int = 30, + top_p: float = 0.9, + ) -> torch.Tensor: + """Predict residual codebook codes 0..num_codebooks-1 autoregressively. + + Args: + slow_ar_hidden: [B, hidden_size] last hidden state from Slow AR. + semantic_token_id: [B] or [B, 1] sampled semantic token IDs (in vocab space). + + Returns: + all_codes: [B, num_codebooks] codes for codebooks 0..num_codebooks-1. + Code 0 is the semantic code (token - semantic_begin_id). + """ + bsz = int(slow_ar_hidden.shape[0]) + num_cb = self._num_codebooks + device = slow_ar_hidden.device + dtype = slow_ar_hidden.dtype + + semantic_begin = self.slow_ar_config.semantic_begin_id + semantic_end = self.slow_ar_config.semantic_end_id + codebook_size = semantic_end - semantic_begin + 1 # 4096 + # Convert vocab-space semantic token to codebook index. + # Clamp to valid range: im_end or other non-semantic tokens map to 0 (pad). + semantic_code = (semantic_token_id.reshape(bsz) - semantic_begin).clamp(min=0, max=codebook_size - 1) + + all_codes = torch.empty(bsz, num_cb, dtype=torch.long, device=device) + all_codes[:, 0] = semantic_code + + self._ensure_buffers(bsz, device, dtype) + self._setup_compile() + + embed_buf = self._embed_buf + pos_ids = self._pos_ids + model_fwd = self._compiled_model_fwd + + # Position 0: projected Slow AR hidden state. + projected = self.fast_project_in(slow_ar_hidden.reshape(bsz, -1)) + embed_buf[:bsz, 0, :] = projected + + # Position 1: embedding of semantic code. + code_embed = self.fast_embeddings(semantic_code.clamp(min=0)) + embed_buf[:bsz, 1, :] = code_embed + + use_sampling = do_sample and temperature > 0 + inv_temperature = 1.0 / max(temperature, 1e-6) if use_sampling else 0.0 + + # Residual codebook size (1024) vs semantic codebook size (4096). + # The fast_output head has codebook_size (4096) outputs, but residual + # codebooks only have 1024 entries. Truncate logits for steps > 0. + residual_codebook_size = 1024 + + for step in range(1, num_cb): + seq_len = step + 1 + step_input = embed_buf[:bsz, :seq_len, :] + step_pos_ids = pos_ids[:seq_len] if bsz == 1 else pos_ids[:seq_len].repeat(bsz) + + hidden_out = model_fwd(step_input, step_pos_ids) + logits = self.fast_output(self.fast_norm(hidden_out[:, -1, :])) + + # Residual codebooks (step >= 1) only have 1024 entries. + if step >= 1: + logits = logits[:, :residual_codebook_size] + + if use_sampling: + scaled = logits * inv_temperature + if top_k > 0: + topk_vals, _ = scaled.topk(min(top_k, scaled.shape[-1]), dim=-1) + scaled = scaled.masked_fill(scaled < topk_vals[:, -1:], float("-inf")) + if top_p < 1.0: + sorted_logits, sorted_indices = torch.sort(scaled, descending=True) + cumulative_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1) + sorted_indices_to_remove = cumulative_probs - F.softmax(sorted_logits, dim=-1) >= top_p + sorted_logits[sorted_indices_to_remove] = float("-inf") + scaled = sorted_logits.scatter(1, sorted_indices, sorted_logits) + probs = F.softmax(scaled, dim=-1) + next_ids = torch.multinomial(probs, num_samples=1) + else: + next_ids = logits.argmax(dim=-1, keepdim=True) + + all_codes[:, step] = next_ids.reshape(bsz) + + if step < num_cb - 1: + new_embed = self.fast_embeddings(next_ids.reshape(bsz)) + embed_buf[:bsz, step + 1, :] = new_embed + + return all_codes + + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: + """Load weights -- Fast AR weights have already been remapped by the parent.""" + with set_current_vllm_config(self._vllm_config): + params_dict = dict(self.named_parameters(remove_duplicate=False)) + loaded: set[str] = set() + + stacked_params_mapping = [ + ("qkv_proj", "q_proj", "q"), + ("qkv_proj", "k_proj", "k"), + ("qkv_proj", "v_proj", "v"), + ("gate_up_proj", "gate_proj", 0), + ("gate_up_proj", "up_proj", 1), + ] + + for name, loaded_weight in weights: + if "rotary_emb.inv_freq" in name: + continue + + handled = False + for param_name, weight_name, shard_id in stacked_params_mapping: + if weight_name not in name: + continue + mapped = name.replace(weight_name, param_name) + if mapped not in params_dict: + continue + param = params_dict[mapped] + weight_loader = getattr(param, "weight_loader", default_weight_loader) + if weight_loader == default_weight_loader: + weight_loader(param, loaded_weight) + else: + weight_loader(param, loaded_weight, shard_id) + loaded.add(mapped) + handled = True + break + + if handled: + continue + + if name in params_dict: + param = params_dict[name] + weight_loader = getattr(param, "weight_loader", default_weight_loader) + weight_loader(param, loaded_weight) + loaded.add(name) + + return loaded diff --git a/vllm_omni/model_executor/models/fish_speech/fish_speech_slow_ar.py b/vllm_omni/model_executor/models/fish_speech/fish_speech_slow_ar.py new file mode 100644 index 000000000..9c41d87f9 --- /dev/null +++ b/vllm_omni/model_executor/models/fish_speech/fish_speech_slow_ar.py @@ -0,0 +1,669 @@ +"""Fish Speech S2 Pro -- Slow AR model (Stage 0). + +Uses vLLM's ``Qwen3Model`` as the transformer backbone. Adds: + - Multi-codebook input embedding (text + summed codebook embeddings at + semantic-token positions). + - Semantic logit masking. + - Nested Fast AR for residual codebook prediction (``talker_mtp``). + - ``preprocess`` / ``postprocess`` hooks for vLLM-omni's AR scheduler. + +Analogous to ``Qwen3TTSTalkerForConditionalGeneration`` in qwen3_tts. +""" + +from __future__ import annotations + +import dataclasses +import math +from collections.abc import Iterable +from typing import Any + +import torch +import torch.nn as nn +from transformers import AutoTokenizer +from vllm.config import VllmConfig +from vllm.distributed import get_pp_group +from vllm.logger import init_logger +from vllm.model_executor.layers.logits_processor import LogitsProcessor +from vllm.model_executor.layers.vocab_parallel_embedding import ParallelLMHead +from vllm.model_executor.model_loader.weight_utils import default_weight_loader +from vllm.model_executor.models.qwen3 import Qwen3Model +from vllm.model_executor.models.utils import PPMissingLayer, maybe_prefix +from vllm.sequence import IntermediateTensors + +from vllm_omni.model_executor.models.output_templates import OmniOutput + +from .configuration_fish_speech import FishSpeechConfig, FishSpeechFastARConfig, FishSpeechSlowARConfig +from .fish_speech_fast_ar import FishSpeechFastAR + +logger = init_logger(__name__) + + +def _remap_fish_speech_weights( + weights: Iterable[tuple[str, torch.Tensor]], + n_head: int, + n_local_heads: int, + head_dim: int, + fast_n_head: int, + fast_n_local_heads: int, + fast_head_dim: int, +) -> Iterable[tuple[str, torch.Tensor]]: + """Transform Fish Speech HF weight names/values to Qwen3-compatible format. + + Key transformations: + - ``wqkv`` → split into ``q_proj``, ``k_proj``, ``v_proj`` + - ``wo`` → ``o_proj`` + - ``w1`` → ``gate_proj``, ``w3`` → ``up_proj``, ``w2`` → ``down_proj`` + - ``attention_norm`` → ``input_layernorm`` + - ``ffn_norm`` → ``post_attention_layernorm`` + - ``text_model.model.embeddings`` → ``model.embed_tokens`` + - ``audio_decoder.*`` → ``fast_ar.*`` with similar transforms + """ + q_size_text = n_head * head_dim + kv_size_text = n_local_heads * head_dim + q_size_fast = fast_n_head * fast_head_dim + kv_size_fast = fast_n_local_heads * fast_head_dim + + for name, tensor in weights: + # --- Text model (Slow AR) --- + if name.startswith("text_model.model."): + suffix = name[len("text_model.model.") :] + + # Embeddings + if suffix == "embeddings.weight": + yield "model.embed_tokens.weight", tensor + continue + + # Norm + if suffix == "norm.weight": + yield "model.norm.weight", tensor + continue + + # Layer weights + if suffix.startswith("layers."): + # layers.{N}.attention.wqkv.weight → split into q/k/v + if ".attention.wqkv.weight" in suffix: + layer_prefix = suffix.split(".attention.wqkv.weight")[0] + q = tensor[:q_size_text, :] + k = tensor[q_size_text : q_size_text + kv_size_text, :] + v = tensor[q_size_text + kv_size_text :, :] + yield f"model.{layer_prefix}.self_attn.q_proj.weight", q + yield f"model.{layer_prefix}.self_attn.k_proj.weight", k + yield f"model.{layer_prefix}.self_attn.v_proj.weight", v + continue + + new_suffix = suffix + new_suffix = new_suffix.replace(".attention.wo.", ".self_attn.o_proj.") + new_suffix = new_suffix.replace(".attention.q_norm.", ".self_attn.q_norm.") + new_suffix = new_suffix.replace(".attention.k_norm.", ".self_attn.k_norm.") + new_suffix = new_suffix.replace(".attention_norm.", ".input_layernorm.") + new_suffix = new_suffix.replace(".feed_forward.w1.", ".mlp.gate_proj.") + new_suffix = new_suffix.replace(".feed_forward.w3.", ".mlp.up_proj.") + new_suffix = new_suffix.replace(".feed_forward.w2.", ".mlp.down_proj.") + new_suffix = new_suffix.replace(".ffn_norm.", ".post_attention_layernorm.") + yield f"model.{new_suffix}", tensor + continue + + # Fallback for any other text_model.model.* weights + yield f"model.{suffix}", tensor + continue + + # --- Audio decoder (Fast AR) --- + if name.startswith("audio_decoder."): + suffix = name[len("audio_decoder.") :] + + # Codebook embeddings (belongs to the main model, not Fast AR). + if suffix == "codebook_embeddings.weight": + yield "codebook_embeddings.weight", tensor + continue + + # Fast AR embeddings, output, norm. + if suffix == "embeddings.weight": + yield "fast_ar.fast_embeddings.weight", tensor + continue + if suffix == "output.weight": + yield "fast_ar.fast_output.weight", tensor + continue + if suffix == "norm.weight": + yield "fast_ar.fast_norm.weight", tensor + continue + + # Fast AR projection in. + if suffix.startswith("fast_project_in."): + yield f"fast_ar.fast_project_in.{suffix[len('fast_project_in.') :]}", tensor + continue + + # Fast AR layer weights. + if suffix.startswith("layers."): + if ".attention.wqkv.weight" in suffix: + layer_prefix = suffix.split(".attention.wqkv.weight")[0] + q = tensor[:q_size_fast, :] + k = tensor[q_size_fast : q_size_fast + kv_size_fast, :] + v = tensor[q_size_fast + kv_size_fast :, :] + yield f"fast_ar.model.{layer_prefix}.self_attn.q_proj.weight", q + yield f"fast_ar.model.{layer_prefix}.self_attn.k_proj.weight", k + yield f"fast_ar.model.{layer_prefix}.self_attn.v_proj.weight", v + continue + + new_suffix = suffix + new_suffix = new_suffix.replace(".attention.wo.", ".self_attn.o_proj.") + new_suffix = new_suffix.replace(".attention.q_norm.", ".self_attn.q_norm.") + new_suffix = new_suffix.replace(".attention.k_norm.", ".self_attn.k_norm.") + new_suffix = new_suffix.replace(".attention_norm.", ".input_layernorm.") + new_suffix = new_suffix.replace(".feed_forward.w1.", ".mlp.gate_proj.") + new_suffix = new_suffix.replace(".feed_forward.w3.", ".mlp.up_proj.") + new_suffix = new_suffix.replace(".feed_forward.w2.", ".mlp.down_proj.") + new_suffix = new_suffix.replace(".ffn_norm.", ".post_attention_layernorm.") + yield f"fast_ar.model.{new_suffix}", tensor + continue + + yield f"fast_ar.{suffix}", tensor + continue + + # Pass through any other weights. + yield name, tensor + + +class FishSpeechSlowARForConditionalGeneration(nn.Module): + """vLLM-AR Slow AR model for Fish Speech S2 Pro. + + Stage 0: text → semantic tokens (+ residual codebook codes via Fast AR). + """ + + def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""): + super().__init__() + self.vllm_config = vllm_config + self.model_path = vllm_config.model_config.model + + config: FishSpeechConfig = vllm_config.model_config.hf_config # type: ignore[assignment] + self.config = config + self.text_config: FishSpeechSlowARConfig = config.text_config + self.fast_ar_config: FishSpeechFastARConfig = config.audio_decoder_config + + self._semantic_begin_id = int(config.semantic_start_token_id) + self._semantic_end_id = int(config.semantic_end_token_id) + self._audio_pad_token_id = int(config.audio_pad_token_id) + self._codebook_size = int(self.text_config.codebook_size) + self._num_codebooks = int(self.text_config.num_codebooks) + + self.have_multimodal_outputs = True + self.has_preprocess = True + self.has_postprocess = True + self.mtp_hidden_size = int(self.text_config.hidden_size) + self.talker_mtp_output_key = "audio_codes" + + # Qwen3 transformer backbone. + self.model = Qwen3Model(vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")) + + # Fish Speech uses interleaved (GPT-J) RoPE, not NeoX style. + # vLLM's Qwen3Attention defaults to NeoX (is_neox_style=True). + # Replace with interleaved RoPE to match training. + self._fix_rope_style() + + if get_pp_group().is_last_rank: + self.lm_head = ParallelLMHead( + self.text_config.vocab_size, + self.text_config.hidden_size, + quant_config=vllm_config.quant_config, + prefix=maybe_prefix(prefix, "lm_head"), + ) + else: + self.lm_head = PPMissingLayer() + + self.logits_processor = LogitsProcessor(self.text_config.vocab_size) + self.make_empty_intermediate_tensors = self.model.make_empty_intermediate_tensors + + # Multi-codebook embedding table: codebook_size * num_codebooks entries. + self.codebook_embeddings = nn.Embedding( + self._codebook_size * self._num_codebooks, + self.text_config.hidden_size, + ) + + # Fast AR (residual codebook predictor). + predictor_compilation = dataclasses.replace(vllm_config.compilation_config) + predictor_compilation.static_forward_context = {} + self._fast_ar_vllm_config = dataclasses.replace(vllm_config, compilation_config=predictor_compilation) + from vllm.config.vllm import set_current_vllm_config as _set_cfg + + with _set_cfg(self._fast_ar_vllm_config): + self.fast_ar = FishSpeechFastAR( + vllm_config=self._fast_ar_vllm_config, + config=self.fast_ar_config, + slow_ar_config=self.text_config, + prefix="fast_ar", + ) + + # Constant logit mask: allow only semantic tokens + im_end. + vocab = int(self.text_config.vocab_size) + semantic_mask = torch.zeros((vocab,), dtype=torch.bool) + lo = self._semantic_begin_id + hi = min(self._semantic_end_id + 1, vocab) + if hi > lo: + semantic_mask[lo:hi] = True + # Also allow <|im_end|> (token 151645 in Qwen3 tokeniser). + im_end_id = 151645 + if im_end_id < vocab: + semantic_mask[im_end_id] = True + self.register_buffer("_semantic_allowed_mask", semantic_mask, persistent=False) + + # Tokeniser (lazy). + self._tokenizer = None + + def _fix_rope_style(self) -> None: + """Replace NeoX-style RoPE with interleaved (GPT-J) style. + + Fish Speech was trained with interleaved RoPE (complex-number pairs), + but vLLM's Qwen3Attention defaults to NeoX style. We rebuild the + rotary embedding with ``is_neox_style=False`` for each attention layer. + """ + from vllm.model_executor.layers.rotary_embedding import get_rope + + for layer in self.model.layers: + attn = layer.self_attn + # Extract parameters from the existing RoPE to rebuild it. + head_dim = attn.head_dim + max_position = self.text_config.max_position_embeddings + rope_params = getattr(self.text_config, "rope_scaling", None) or {} + rope_params.setdefault("rope_theta", getattr(self.text_config, "rope_theta", 1000000.0)) + attn.rotary_emb = get_rope( + head_size=head_dim, + max_position=max_position, + is_neox_style=False, + rope_parameters=rope_params, + ) + logger.info("Fixed RoPE style to interleaved (GPT-J) for %d layers", len(self.model.layers)) + + # -------------------- vLLM required hooks -------------------- + + def embed_input_ids(self, input_ids: torch.Tensor, **_: Any) -> torch.Tensor: + return self.model.embed_input_ids(input_ids) + + def forward( + self, + input_ids: torch.Tensor, + positions: torch.Tensor, + intermediate_tensors: IntermediateTensors | None = None, + inputs_embeds: torch.Tensor | None = None, + **_: Any, + ) -> torch.Tensor | IntermediateTensors: + return self.model(input_ids, positions, intermediate_tensors, inputs_embeds) + + def compute_logits( + self, + hidden_states: torch.Tensor | OmniOutput, + sampling_metadata: Any = None, + ) -> torch.Tensor | None: + if isinstance(hidden_states, OmniOutput): + hidden_states = hidden_states.text_hidden_states + if hidden_states is None: + return None + logits = self.logits_processor(self.lm_head, hidden_states) + if logits is None: + return None + + # Mask to semantic tokens + im_end only. + logits = logits.masked_fill(~self._semantic_allowed_mask, float("-inf")) + return logits + + # -------------------- Omni multimodal output plumbing -------------------- + + def make_omni_output(self, model_outputs: torch.Tensor | OmniOutput, **kwargs: Any) -> OmniOutput: + if isinstance(model_outputs, OmniOutput): + return model_outputs + + hidden = model_outputs + info_dicts = kwargs.get("model_intermediate_buffer") + if info_dicts is None: + info_dicts = kwargs.get("runtime_additional_information") or [] + + audio_codes_list: list[torch.Tensor] = [] + for info in info_dicts: + if not isinstance(info, dict): + continue + ac = info.get("audio_codes") + if isinstance(ac, torch.Tensor): + audio_codes_list.append(ac) + + if not audio_codes_list: + logger.debug("make_omni_output: no audio_codes found in info_dicts (len=%d)", len(info_dicts)) + return OmniOutput(text_hidden_states=hidden, multimodal_outputs={}) + + audio_codes = torch.cat(audio_codes_list, dim=0) + span_len = int(audio_codes.shape[0]) + hidden = hidden[:span_len] + mm: dict[str, torch.Tensor] = {"audio_codes": audio_codes} + return OmniOutput(text_hidden_states=hidden, multimodal_outputs=mm) + + # -------------------- preprocess / postprocess -------------------- + + def preprocess( + self, + input_ids: torch.Tensor, + input_embeds: torch.Tensor | None, + **info_dict: Any, + ) -> tuple[torch.Tensor, torch.Tensor, dict[str, Any]]: + additional_information = info_dict.get("additional_information") + if isinstance(additional_information, dict): + merged: dict[str, Any] = {k: v for k, v in info_dict.items() if k != "additional_information"} + for k, v in additional_information.items(): + merged.setdefault(k, v) + info_dict = merged + + span_len = int(input_ids.shape[0]) + if span_len <= 0: + return input_ids, input_embeds if input_embeds is not None else self.embed_input_ids(input_ids), {} + + if span_len > 1: + # --- Prefill --- + prompt_embeds_cpu = info_dict.get("slow_ar_prompt_embeds") + is_first_prefill = not isinstance(prompt_embeds_cpu, torch.Tensor) or prompt_embeds_cpu.ndim != 2 + dev = input_ids.device + + if is_first_prefill: + prompt_embeds = self._build_prefill_embeds(input_ids, info_dict) + prompt_embeds_cpu = prompt_embeds.detach().to("cpu").contiguous() + + info_update: dict[str, Any] = { + "slow_ar_prompt_embeds": prompt_embeds_cpu, + "prefill_offset": 0, + } + + take = prompt_embeds_cpu[:span_len] + if int(take.shape[0]) < span_len: + pad_n = span_len - int(take.shape[0]) + pad_embed = self.embed_input_ids( + torch.tensor([self._audio_pad_token_id], device=dev, dtype=torch.long) + ).reshape(1, -1) + take = torch.cat([take, pad_embed.detach().cpu().expand(pad_n, -1)], dim=0) + prompt_embeds = take.to(device=dev, dtype=torch.bfloat16) + info_update["prefill_offset"] = span_len + + zeros = torch.zeros( + (prompt_embeds.shape[0], self._num_codebooks), + device=dev, + dtype=torch.long, + ) + info_update["audio_codes"] = zeros + + input_ids_out = input_ids.clone() + input_ids_out[:] = self._audio_pad_token_id + return input_ids_out, prompt_embeds, info_update + + else: + # Subsequent prefill chunk. + offset = int(info_dict.get("prefill_offset", 0) or 0) + s = max(0, min(offset, int(prompt_embeds_cpu.shape[0]))) + e = max(0, min(offset + span_len, int(prompt_embeds_cpu.shape[0]))) + take = prompt_embeds_cpu[s:e] + if int(take.shape[0]) < span_len: + pad_n = span_len - int(take.shape[0]) + pad_embed = self.embed_input_ids( + torch.tensor([self._audio_pad_token_id], device=dev, dtype=torch.long) + ).reshape(1, -1) + take = torch.cat([take, pad_embed.detach().cpu().expand(pad_n, -1)], dim=0) + prompt_embeds = take.to(device=dev, dtype=torch.bfloat16) + + zeros = torch.zeros((prompt_embeds.shape[0], self._num_codebooks), device=dev, dtype=torch.long) + return ( + input_ids.clone().fill_(self._audio_pad_token_id), + prompt_embeds, + { + "prefill_offset": offset + span_len, + "audio_codes": zeros, + }, + ) + + # --- Decode: span_len == 1 --- + dev = input_ids.device + + last_hidden_cpu = info_dict.get("last_slow_ar_hidden") + if not isinstance(last_hidden_cpu, torch.Tensor): + # First decode step after prefill -- just embed the token directly. + logger.warning( + "preprocess decode: last_slow_ar_hidden not found (keys=%s), " + "returning plain embed (mtp_inputs will NOT be set)", + list(info_dict.keys()), + ) + embeds = self.embed_input_ids(input_ids.reshape(1, 1).to(torch.long)).reshape(1, -1) + return input_ids, embeds.to(dtype=torch.bfloat16), {} + + token_embed = self.embed_input_ids(input_ids.reshape(1, 1).to(torch.long)).to( + device=dev, dtype=torch.bfloat16 + ) # [1, 1, H] + + # Codebook embeddings are added by talker_mtp (using the CURRENT step's + # FastAR hidden state) instead of here (which would use the PREVIOUS step's + # codes, one step too old). Preprocess always returns plain text embed. + inputs_embeds_out = token_embed.reshape(1, -1) + + info_update = { + "mtp_inputs": ( + last_hidden_cpu.to(device=dev, dtype=torch.bfloat16).reshape(1, -1), + torch.zeros(1, self.text_config.hidden_size, device=dev, dtype=torch.bfloat16), + ), + } + return input_ids, inputs_embeds_out, info_update + + def postprocess(self, hidden_states: torch.Tensor, **_: Any) -> dict[str, Any]: + if hidden_states.numel() == 0: + logger.debug("postprocess: empty hidden_states") + return {} + last = hidden_states[-1, :].detach().to("cpu").contiguous() + logger.debug("postprocess: saved last_slow_ar_hidden shape=%s", tuple(last.shape)) + return {"last_slow_ar_hidden": last} + + # -------------------- prompt construction -------------------- + + def _get_tokenizer(self): + if self._tokenizer is None: + self._tokenizer = AutoTokenizer.from_pretrained(self.model_path, trust_remote_code=True) + return self._tokenizer + + def _build_prefill_embeds( + self, + input_ids: torch.Tensor, + info_dict: dict[str, Any], + ) -> torch.Tensor: + """Build prefill embeddings, adding codebook embeddings at semantic positions. + + For text-only prefill (no reference audio), this is just embed_input_ids. + For voice cloning, reference codes are embedded with codebook offsets. + """ + dev = input_ids.device + # Basic text embeddings. + base_embeds = self.embed_input_ids(input_ids.reshape(1, -1).to(torch.long)) # [1, T, H] + + # Check for reference codebook codes (for voice cloning). + ref_codes = info_dict.get("ref_codes") + if not isinstance(ref_codes, torch.Tensor) or ref_codes.numel() == 0: + return base_embeds.squeeze(0).to(dtype=torch.bfloat16) + + # ref_codes: [T_ref, num_codebooks] -- codebook codes for reference audio positions. + ref_codes = ref_codes.to(device=dev, dtype=torch.long) + ref_positions = info_dict.get("ref_positions") + if not isinstance(ref_positions, torch.Tensor): + return base_embeds.squeeze(0).to(dtype=torch.bfloat16) + + ref_positions = ref_positions.to(device=dev, dtype=torch.long).reshape(-1) + seq_len = int(input_ids.shape[0]) + codebook_sum = torch.zeros_like(base_embeds) # [1, T, H] + + for pos_idx in range(int(ref_positions.shape[0])): + pos = int(ref_positions[pos_idx].item()) + if pos < 0 or pos >= seq_len: + continue + for cb_idx in range(min(int(ref_codes.shape[1]), self._num_codebooks)): + code = ref_codes[pos_idx, cb_idx].clamp(min=0) + code_with_offset = code + cb_idx * self._codebook_size + emb = self.codebook_embeddings(code_with_offset.unsqueeze(0)) + codebook_sum[0, pos, :] += emb.squeeze(0).to(dtype=base_embeds.dtype) + + result = base_embeds + codebook_sum + return result.squeeze(0).to(dtype=torch.bfloat16) + + # -------------------- GPU-side MTP fast-path -------------------- + + @torch.inference_mode() + def talker_mtp( + self, + input_ids: torch.Tensor, + input_embeds: torch.Tensor, + last_talker_hidden: torch.Tensor, + text_step: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor]: + """GPU fast-path: run Fast AR to predict residual codebook codes. + + Returns (inputs_embeds, audio_codes). + + The embedding is: text_embed(token) + sum(codebook_embed(code_i + i * codebook_size)) + where codes come from FastAR(last_talker_hidden). + + This matches the reference Fish Speech inference flow: + - At step t, the Slow AR embedding includes codes from FastAR(hidden_{t-1}) + - last_talker_hidden IS hidden_{t-1} (from postprocess of the previous step) + - Preprocess provides the plain text embed; we add codebook embeddings here + """ + bsz = int(input_ids.shape[0]) + dev = input_embeds.device + + input_ids = input_ids.reshape(bsz, 1).to(dtype=torch.long, device=dev) + past_hidden = last_talker_hidden.reshape(bsz, -1).to(dtype=torch.bfloat16, device=dev) + + # Run Fast AR to predict all num_codebooks codes. + audio_codes = self.fast_ar( + slow_ar_hidden=past_hidden, + semantic_token_id=input_ids.reshape(bsz), + do_sample=True, + temperature=0.8, + top_k=30, + top_p=0.9, + ) # [B, num_codebooks] + + # Add codebook embeddings to the input embedding (from preprocess). + # This ensures the Slow AR sees codes from FastAR(hidden_{t-1}). + inputs_embeds_out = input_embeds.reshape(bsz, -1).clone() + + for b in range(bsz): + token_id = int(input_ids[b, 0].item()) + is_semantic = self._semantic_begin_id <= token_id <= self._semantic_end_id + if is_semantic: + codes = audio_codes[b] # [num_codebooks] + codebook_sum = torch.zeros(self.text_config.hidden_size, device=dev, dtype=torch.bfloat16) + for i in range(self._num_codebooks): + code_with_offset = codes[i].clamp(min=0) + i * self._codebook_size + emb = self.codebook_embeddings(code_with_offset.unsqueeze(0)) + codebook_sum += emb.squeeze(0).to(dtype=torch.bfloat16) + + # Normalize by sqrt(num_codebooks + 1) as in the reference model + # (scale_codebook_embeddings=True for fish_qwen3_omni). + inputs_embeds_out[b] = (inputs_embeds_out[b] + codebook_sum) / math.sqrt(self._num_codebooks + 1) + + return inputs_embeds_out, audio_codes.to(dtype=torch.long) + + # -------------------- Prompt length estimation -------------------- + + @staticmethod + def estimate_prompt_len_from_additional_information( + additional_information: dict[str, Any] | None, + **kwargs: Any, + ) -> int: + """Estimate prompt length for placeholder allocation.""" + info = additional_information or {} + text = info.get("text", [""])[0] if isinstance(info.get("text"), list) else info.get("text", "") + # Conservative estimate: tokenize text length + overhead. + return max(2, len(str(text)) // 2 + 64) + + # -------------------- Weight loading -------------------- + + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: + """Load weights with Fish Speech → Qwen3 format transformation. + + Transforms weight names (wqkv → q/k/v split, w1/w2/w3 → gate/up/down, + etc.) and routes to the correct sub-modules. + """ + n_head = self.text_config.num_attention_heads + n_local_heads = self.text_config.num_key_value_heads + head_dim = self.text_config.head_dim + fast_n_head = self.fast_ar_config.num_attention_heads + fast_n_local_heads = self.fast_ar_config.num_key_value_heads + fast_head_dim = self.fast_ar_config.head_dim + + remapped = _remap_fish_speech_weights( + weights, + n_head, + n_local_heads, + head_dim, + fast_n_head, + fast_n_local_heads, + fast_head_dim, + ) + + # Qwen3Model uses stacked_params_mapping for q/k/v → qkv_proj + # and gate/up → gate_up_proj. Feed the remapped weights through + # the standard Qwen3 loading path. + stacked_params_mapping = [ + ("qkv_proj", "q_proj", "q"), + ("qkv_proj", "k_proj", "k"), + ("qkv_proj", "v_proj", "v"), + ("gate_up_proj", "gate_proj", 0), + ("gate_up_proj", "up_proj", 1), + ] + + params_dict = dict(self.named_parameters(remove_duplicate=False)) + loaded_params: set[str] = set() + + for name, loaded_weight in remapped: + if "rotary_emb.inv_freq" in name: + continue + + # Handle tied embeddings → lm_head. + if name == "model.embed_tokens.weight" and self.text_config.tie_word_embeddings: + # Also load into lm_head if present. + lm_key = "lm_head.weight" + if lm_key in params_dict: + p = params_dict[lm_key] + wl = getattr(p, "weight_loader", default_weight_loader) + wl(p, loaded_weight) + loaded_params.add(lm_key) + + # Try stacked params mapping (q/k/v → qkv_proj, gate/up → gate_up_proj). + handled = False + for param_name, weight_name, shard_id in stacked_params_mapping: + if weight_name not in name: + continue + mapped = name.replace(weight_name, param_name) + if mapped not in params_dict: + continue + param = params_dict[mapped] + weight_loader = getattr(param, "weight_loader", default_weight_loader) + if weight_loader == default_weight_loader: + weight_loader(param, loaded_weight) + else: + weight_loader(param, loaded_weight, shard_id) + loaded_params.add(mapped) + handled = True + break + + if handled: + continue + + # Direct parameter mapping. + if name in params_dict: + param = params_dict[name] + weight_loader = getattr(param, "weight_loader", default_weight_loader) + weight_loader(param, loaded_weight) + loaded_params.add(name) + + logger.info("Loaded %d weights for FishSpeechSlowARForConditionalGeneration", len(loaded_params)) + + # Truncate RoPE cos/sin caches to bf16 precision to match training. + # Without this, f32 RoPE values cause logit divergence and premature EOS. + truncated = 0 + for module in self.modules(): + if hasattr(module, "cos_sin_cache") and isinstance(module.cos_sin_cache, torch.Tensor): + cache = module.cos_sin_cache + module.cos_sin_cache = cache.to(torch.bfloat16).to(cache.dtype) + truncated += 1 + if truncated: + logger.info("Truncated %d RoPE cos_sin_cache buffers to bf16 precision", truncated) + + return loaded_params diff --git a/vllm_omni/model_executor/models/registry.py b/vllm_omni/model_executor/models/registry.py index 9bf2b4fdb..f708b6ff9 100644 --- a/vllm_omni/model_executor/models/registry.py +++ b/vllm_omni/model_executor/models/registry.py @@ -129,6 +129,17 @@ _OMNI_MODELS = { "hunyuan_image3", "HunyuanImage3ForConditionalGeneration", ), + ## fish_speech (Fish Speech S2 Pro) + "FishSpeechSlowARForConditionalGeneration": ( + "fish_speech", + "fish_speech_slow_ar", + "FishSpeechSlowARForConditionalGeneration", + ), + "FishSpeechDACDecoder": ( + "fish_speech", + "fish_speech_dac_decoder", + "FishSpeechDACDecoder", + ), } diff --git a/vllm_omni/model_executor/stage_configs/fish_speech_s2_pro.yaml b/vllm_omni/model_executor/stage_configs/fish_speech_s2_pro.yaml new file mode 100644 index 000000000..7f5ba1202 --- /dev/null +++ b/vllm_omni/model_executor/stage_configs/fish_speech_s2_pro.yaml @@ -0,0 +1,97 @@ +async_chunk: true +stage_args: + - stage_id: 0 + stage_type: llm + runtime: + devices: "0" + max_batch_size: 4 + engine_args: + model_stage: fish_speech_slow_ar + model_arch: FishSpeechSlowARForConditionalGeneration + hf_overrides: + architectures: [FishSpeechSlowARForConditionalGeneration] + worker_type: ar + scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler + enforce_eager: false + trust_remote_code: true + async_scheduling: false + enable_prefix_caching: false + engine_output_type: latent + gpu_memory_utilization: 0.4 + distributed_executor_backend: "mp" + max_num_batched_tokens: 2048 + max_model_len: 32768 + custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.fish_speech.slow_ar_to_dac_decoder_async_chunk + output_connectors: + to_stage_1: connector_of_shared_memory + default_sampling_params: + temperature: 0.8 + top_k: 30 + top_p: 0.9 + max_tokens: 200 + seed: 42 + detokenize: false + repetition_penalty: 1.0 + # <|im_end|> token -- stop when model emits end-of-turn. + stop_token_ids: [151645] + + - stage_id: 1 + stage_type: llm + runtime: + devices: "0" + max_batch_size: 1 + engine_args: + model_stage: dac_decoder + model_arch: FishSpeechDACDecoder + hf_overrides: + architectures: [FishSpeechDACDecoder] + worker_type: generation + scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler + enforce_eager: true + trust_remote_code: true + async_scheduling: false + enable_prefix_caching: false + engine_output_type: audio + gpu_memory_utilization: 0.2 + distributed_executor_backend: "mp" + max_num_batched_tokens: 8192 + max_model_len: 32768 + engine_input_source: [0] + final_output: true + final_output_type: audio + input_connectors: + from_stage_0: connector_of_shared_memory + default_sampling_params: + temperature: 0.0 + top_p: 1.0 + top_k: -1 + max_tokens: 65536 + seed: 42 + detokenize: true + repetition_penalty: 1.0 + +runtime: + enabled: true + defaults: + window_size: -1 + max_inflight: 1 + + connectors: + connector_of_shared_memory: + name: SharedMemoryConnector + extra: + shm_threshold_bytes: 65536 + codec_streaming: true + connector_get_sleep_s: 0.01 + connector_get_max_wait_first_chunk: 3000 + connector_get_max_wait: 300 + # Chunk sizes for streaming -- ~21 Hz codec. + # 25 frames ≈ 1.16s of audio at 21.5 Hz. + codec_chunk_frames: 25 + codec_left_context_frames: 25 + initial_codec_chunk_frames: 0 + + edges: + - from: 0 + to: 1 + window_size: -1 diff --git a/vllm_omni/model_executor/stage_input_processors/fish_speech.py b/vllm_omni/model_executor/stage_input_processors/fish_speech.py new file mode 100644 index 000000000..5be70bc18 --- /dev/null +++ b/vllm_omni/model_executor/stage_input_processors/fish_speech.py @@ -0,0 +1,151 @@ +"""Stage input processor for Fish Speech S2 Pro: Slow AR → DAC Decoder.""" + +from typing import Any + +import torch +from vllm.logger import init_logger + +logger = init_logger(__name__) + +_NUM_CODEBOOKS = 10 # 1 semantic + 9 residual + + +def _extract_last_frame(pooling_output: dict[str, Any]) -> torch.Tensor | None: + """Extract the last frame of audio codes from the pooling output.""" + audio_codes = pooling_output.get("audio_codes") + if not isinstance(audio_codes, torch.Tensor) or audio_codes.numel() == 0: + return None + if audio_codes.ndim == 2: + frame = audio_codes[-1] + if frame.numel() == 0 or not bool(frame.any().item()): + return None + return frame.to(torch.long).reshape(-1) + if audio_codes.ndim == 1: + return audio_codes.to(torch.long).reshape(-1) + raise ValueError(f"Invalid audio_codes shape for Fish Speech async_chunk: {tuple(audio_codes.shape)}") + + +def slow_ar_to_dac_decoder( + stage_list: list[Any], + engine_input_source: list[int], + prompt: Any = None, + requires_multimodal_data: bool = False, +) -> list[Any]: + """Non-async processor: wait for Slow AR to finish, then pass all codes to DAC decoder.""" + from vllm_omni.inputs.data import OmniTokensPrompt + from vllm_omni.model_executor.stage_input_processors.qwen3_omni import _validate_stage_inputs + + slow_ar_outputs = _validate_stage_inputs(stage_list, engine_input_source) + dac_inputs: list[OmniTokensPrompt] = [] + + for output in slow_ar_outputs: + out = output.outputs[0] + # audio_codes shape: [num_frames, num_codebooks] + audio_codes = out.multimodal_output["audio_codes"].to(torch.long) + # Filter zero-padded frames. + valid_mask = audio_codes.any(dim=1) + audio_codes = audio_codes[valid_mask] + # Codebook-major flat: [num_codebooks * num_frames] + codec_codes = audio_codes.transpose(0, 1).cpu().reshape(-1).tolist() + dac_inputs.append( + OmniTokensPrompt( + prompt_token_ids=codec_codes, + multi_modal_data=None, + mm_processor_kwargs=None, + additional_information=None, + ) + ) + return dac_inputs + + +def slow_ar_to_dac_decoder_async_chunk( + transfer_manager: Any, + pooling_output: dict[str, Any] | None, + request: Any, + is_finished: bool = False, +) -> dict[str, Any] | None: + """Async streaming processor: emit code chunks as they are produced. + + Accumulates per-step codes and emits fixed-size chunks with left context + overlap for smooth audio transitions, analogous to + ``talker2code2wav_async_chunk`` in Qwen3 TTS. + """ + request_id = request.external_req_id + finished = bool(is_finished or request.is_finished()) + + if isinstance(pooling_output, dict): + frame = _extract_last_frame(pooling_output) + if frame is not None: + codec_codes = frame.cpu().tolist() + transfer_manager.code_prompt_token_ids[request_id].append(codec_codes) + elif not finished: + return None + + connector = getattr(transfer_manager, "connector", None) + raw_cfg = getattr(connector, "config", {}) or {} + cfg = raw_cfg.get("extra", raw_cfg) if isinstance(raw_cfg, dict) else {} + chunk_size = int(cfg.get("codec_chunk_frames", 25)) + left_context_size_config = int(cfg.get("codec_left_context_frames", 25)) + initial_chunk_size = int(cfg.get("initial_codec_chunk_frames", 0)) + + # Per-request override. + additional_information = getattr(request, "additional_information", None) + if ( + additional_information is not None + and hasattr(additional_information, "entries") + and "initial_codec_chunk_frames" in additional_information.entries + ): + entry = additional_information.entries["initial_codec_chunk_frames"] + if entry.list_data is not None and len(entry.list_data) == 1: + initial_chunk_size = int(entry.list_data[0]) + + if chunk_size <= 0 or left_context_size_config < 0 or initial_chunk_size < 0: + raise ValueError( + f"Invalid codec chunk config: codec_chunk_frames={chunk_size}, " + f"codec_left_context_frames={left_context_size_config}, " + f"initial_codec_chunk_frames={initial_chunk_size}" + ) + if initial_chunk_size > chunk_size: + initial_chunk_size = chunk_size + + length = len(transfer_manager.code_prompt_token_ids[request_id]) + + if length <= 0: + if finished: + return { + "code_predictor_codes": [], + "finished": torch.tensor(True, dtype=torch.bool), + } + return None + + in_initial_phase = initial_chunk_size > 0 and length <= chunk_size + + if in_initial_phase: + already_sent = transfer_manager.put_req_chunk[request_id] * initial_chunk_size + pending = length - already_sent + if pending <= 0: + return None + if pending < initial_chunk_size and not finished: + return None + context_length = min(pending, initial_chunk_size) + left_context_size = max(0, length - context_length) + window_frames = transfer_manager.code_prompt_token_ids[request_id][:length] + else: + initial_coverage = (chunk_size // initial_chunk_size) * initial_chunk_size if initial_chunk_size > 0 else 0 + adjusted = length - initial_coverage + chunk_length = adjusted % chunk_size + if chunk_length != 0 and not finished: + return None + context_length = chunk_length if chunk_length != 0 else chunk_size + end_index = min(length, left_context_size_config + context_length) + left_context_size = max(0, int(end_index - context_length)) + window_frames = transfer_manager.code_prompt_token_ids[request_id][-end_index:] + + # Pack into codebook-major flat codes. + code_predictor_codes = torch.tensor(window_frames).transpose(0, 1).reshape(-1).tolist() + + return { + "code_predictor_codes": code_predictor_codes, + "left_context_size": left_context_size, + "finished": torch.tensor(finished, dtype=torch.bool), + } diff --git a/vllm_omni/transformers_utils/configs/__init__.py b/vllm_omni/transformers_utils/configs/__init__.py index 136a680a3..59b23f914 100644 --- a/vllm_omni/transformers_utils/configs/__init__.py +++ b/vllm_omni/transformers_utils/configs/__init__.py @@ -14,6 +14,9 @@ _CLASS_TO_MODULE: dict[str, str] = { "Mammothmoda2Qwen2_5_VLConfig": "vllm_omni.transformers_utils.configs.mammoth_moda2", "Mammothmoda2Qwen2_5_VLTextConfig": "vllm_omni.transformers_utils.configs.mammoth_moda2", "Mammothmoda2Qwen2_5_VLVisionConfig": "vllm_omni.transformers_utils.configs.mammoth_moda2", + "FishSpeechConfig": "vllm_omni.transformers_utils.configs.fish_speech", + "FishSpeechSlowARConfig": "vllm_omni.transformers_utils.configs.fish_speech", + "FishSpeechFastARConfig": "vllm_omni.transformers_utils.configs.fish_speech", } __all__ = [ @@ -21,6 +24,9 @@ __all__ = [ "Mammothmoda2Qwen2_5_VLConfig", "Mammothmoda2Qwen2_5_VLTextConfig", "Mammothmoda2Qwen2_5_VLVisionConfig", + "FishSpeechConfig", + "FishSpeechSlowARConfig", + "FishSpeechFastARConfig", ] @@ -39,4 +45,5 @@ def __dir__(): # Eagerly import all config modules so their AutoConfig.register() side-effects # run as soon as `vllm_omni.transformers_utils.configs` is imported. +from vllm_omni.transformers_utils.configs import fish_speech as _fish_speech # noqa: F401, E402 from vllm_omni.transformers_utils.configs import mammoth_moda2 as _mammoth_moda2 # noqa: F401, E402 diff --git a/vllm_omni/transformers_utils/configs/fish_speech.py b/vllm_omni/transformers_utils/configs/fish_speech.py new file mode 100644 index 000000000..44b5a67eb --- /dev/null +++ b/vllm_omni/transformers_utils/configs/fish_speech.py @@ -0,0 +1,24 @@ +"""Fish Speech S2 Pro config registration with transformers AutoConfig. + +Registers FishSpeechConfig (model_type="fish_qwen3_omni") and sub-configs +so that ``AutoConfig.from_pretrained("fishaudio/s2-pro")`` returns the +correct config class. +""" + +from transformers import AutoConfig + +from vllm_omni.model_executor.models.fish_speech.configuration_fish_speech import ( + FishSpeechConfig, + FishSpeechFastARConfig, + FishSpeechSlowARConfig, +) + +AutoConfig.register("fish_qwen3_omni", FishSpeechConfig) +AutoConfig.register("fish_qwen3", FishSpeechSlowARConfig) +AutoConfig.register("fish_qwen3_audio_decoder", FishSpeechFastARConfig) + +__all__ = [ + "FishSpeechConfig", + "FishSpeechSlowARConfig", + "FishSpeechFastARConfig", +] From 54a999d031286737d2799d4ea8e4fb13ed58e3d2 Mon Sep 17 00:00:00 2001 From: NATURE Date: Fri, 13 Mar 2026 09:23:32 +0800 Subject: [PATCH 36/38] [skip CI][Docs] add connector design document (#1737) Signed-off-by: natureofnature --- .../mooncake_store_connector.md | 238 +++++++ .../mooncake_transfer_engine_connector.md | 586 ++++++++++++++++++ .../shared_memory_connector.md | 238 ++++++- .../omni_connectors/yuanrong_connector.md | 259 +++++++- 4 files changed, 1317 insertions(+), 4 deletions(-) diff --git a/docs/design/feature/omni_connectors/mooncake_store_connector.md b/docs/design/feature/omni_connectors/mooncake_store_connector.md index 5812a6abe..2ff116859 100644 --- a/docs/design/feature/omni_connectors/mooncake_store_connector.md +++ b/docs/design/feature/omni_connectors/mooncake_store_connector.md @@ -72,3 +72,241 @@ Parameters: For more details, refer to the [Mooncake repository](https://github.com/kvcache-ai/Mooncake). + +--- + +## Design + +### 1. Overview + +`MooncakeStoreConnector` is the storage-oriented remote connector in `vllm_omni/distributed/omni_connectors`. It is built on top of Mooncake distributed store APIs and provides a uniform `put()` / `get()` abstraction for transferring stage payloads across nodes. + +Compared with `SharedMemoryConnector`, this connector is intended for multi-node deployments. Compared with `MooncakeTransferEngineConnector`, it is simpler and more object-oriented: data is written into a distributed store and fetched back by key, rather than transferred through an explicit remote-memory write protocol. + +Its primary role is to provide a general-purpose remote connector for stage payloads when: + +- the pipeline spans multiple hosts +- a shared-memory connector is not possible +- a simpler remote backend is preferred over the RDMA-oriented transfer engine + +### 2. Relationship with the OmniConnector System + +`MooncakeStoreConnector` implements `OmniConnectorBase`, so it integrates with the same connector lifecycle as the other backends: + +- `OmniConnectorFactory` instantiates it from `ConnectorSpec` +- `load_omni_transfer_config()` maps edge config to the connector spec +- All callers (batch forwarding, chunk transfer, KV transfer, etc.) interact with it through the same `put()` / `get()` contract + +This means the rest of the pipeline does not need store-specific logic. It only relies on the generic connector contract. + +### 3. Design Goals + +The connector is designed around the following goals: + +- **Cross-node object transfer** using a single key-based abstraction +- **Minimal connector-specific control plane**, since the store itself is the shared medium +- **Compatibility with arbitrary Python payloads** through the Omni serializer +- **Operational simplicity** compared with the transfer-engine-based remote connector + +The connector is intentionally not optimized for zero-copy tensor movement or direct remote-memory access. + +### 4. Core Design + +#### 4.1 Object-Oriented Transfer Model + +`MooncakeStoreConnector` treats the transport backend as a distributed object store: + +1. Serialize the Python object into bytes. +2. Generate a unique connector key. +3. Store the bytes in Mooncake. +4. Fetch the bytes from Mooncake on the receiver side. +5. Deserialize the bytes back into the original object. + +This design makes the connector conceptually close to a distributed key-value transport. + +#### 4.2 Key Construction + +The connector uses `OmniConnectorBase._make_key()` to derive the internal store key: + +```text +{request_key}@{from_stage}_{to_stage} +``` + +This adds stage routing information to the logical request key and avoids collisions between different pipeline edges. + +#### 4.3 No Extra Metadata Path + +Unlike `SharedMemoryConnector` and `MooncakeTransferEngineConnector`, this connector returns: + +```python +metadata = None +``` + +from `put()`. + +This is a meaningful design difference: + +- the store itself is the shared rendezvous point +- consumers only need the same key +- no transport handle, address, or side-channel metadata needs to be propagated + +As a result, the control plane is simpler for this connector than for the SHM and RDMA variants. + +### 5. Initialization + +#### 5.1 Required Mooncake Components + +The connector requires the Mooncake Python bindings to expose: + +- `MooncakeDistributedStore` +- `ReplicateConfig` + +If these symbols are unavailable, construction fails immediately with `ImportError`. This keeps startup failures explicit and avoids silent fallback behavior. + +#### 5.2 Store Setup + +During `_init_store()`, the connector: + +1. creates `MooncakeDistributedStore()` +2. calls `store.setup(...)` +3. validates the return code +4. creates a `ReplicateConfig` +5. enables `with_soft_pin = True` + +The setup step is completed during connector construction, not lazily at first use. + +### 6. Put / Get Flow + +#### 6.1 Producer Flow: `put()` + +The producer-side flow is: + +1. Validate that the store has been initialized. +2. Serialize the payload into bytes. +3. Build the internal key with stage routing information. +4. Call `store.put(key, serialized_data, self.pin)`. +5. Update metrics and return success. + +The returned tuple is: + +```python +(True, len(serialized_data), None) +``` + +#### 6.2 Consumer Flow: `get()` + +The consumer-side flow is: + +1. Validate that the store has been initialized. +2. Build the same internal key. +3. Poll `store.get(key)` for up to 20 retries. +4. Sleep 50 ms between retries. +5. If data is found, deserialize it and return `(data, payload_size)`. +6. If all retries are exhausted, record a timeout and return `None`. + +This design gives the connector a bounded waiting model rather than an indefinite blocking get. + +### 7. Integration with Stage Communication + +All callers use the connector through the same `put()` / `get()` contract: + +- the sender calls `put()` to serialize and store the payload +- the receiver calls `get()` to retrieve and deserialize it +- no connector-specific metadata is required, since the store key is the rendezvous point + +Because `put()` returns `metadata=None`, the connector is naturally compatible with callers that do not forward metadata (e.g. polling-based flows). The trade-off is that all payloads incur full serialization and deserialization costs, which makes the connector functional but not the highest-performance option for large payloads such as KV cache blocks. + +### 8. Data Flow in the Pipeline + +The end-to-end transfer model is: + +```mermaid +sequenceDiagram + participant SenderStage + participant MooncakeStoreConnector + participant MooncakeStore + participant ReceiverStage + + SenderStage->>MooncakeStoreConnector: put(from_stage, to_stage, put_key, data) + MooncakeStoreConnector->>MooncakeStoreConnector: serialize object + MooncakeStoreConnector->>MooncakeStore: put(internal_key, bytes) + MooncakeStoreConnector-->>SenderStage: (success, size, None) + + ReceiverStage->>MooncakeStoreConnector: get(from_stage, to_stage, get_key) + MooncakeStoreConnector->>MooncakeStore: get(internal_key) + MooncakeStoreConnector->>MooncakeStoreConnector: deserialize bytes + MooncakeStoreConnector-->>ReceiverStage: (data, size) +``` + +This is a store-mediated remote transfer model rather than a direct peer-to-peer transport model. + +### 9. Strengths and Trade-offs + +#### Strengths + +- Simple conceptual model: store by key, fetch by key. +- No connector-specific metadata handoff is required. +- Works naturally for remote multi-node stage transfer. +- Easy to integrate into the existing connector abstraction. + +#### Trade-offs + +- Full serialization and deserialization are always required. +- Large payloads are more expensive than in direct-memory transports. +- Runtime behavior depends on external Mooncake services being available. +- Cleanup semantics are weaker than request-scoped local buffer management. + +### 10. Important Implementation Characteristics + +#### 10.1 Cleanup Is a No-op + +`cleanup()` only logs a debug message and does not actively delete remote data. The current design assumes Mooncake-side lifecycle management rather than explicit per-request removal. + +This keeps the connector implementation simple but means request-level reclamation is not modeled inside the connector itself. + +#### 10.2 Close Releases the Store Handle + +Unlike `SharedMemoryConnector`, where `close()` is a no-op, `MooncakeStoreConnector.close()` performs a meaningful shutdown: + +1. Calls `self.store.close()` to release the Mooncake store handle. +2. Sets `self.store = None` to prevent further operations. + +This ensures the connector releases its connection to the external Mooncake service on shutdown. Errors during close are logged but do not propagate. + +#### 10.3 Health Output Uses a Shared Schema + +`health()` reports: + +- `host` +- `metadata_server` +- `master` +- metrics + +and also includes placeholder fields such as `protocol`, `pool_device`, and `pool_size` to keep a more uniform shape across connector health outputs. This reflects a system-level consistency choice rather than a store-specific design need. + +#### 10.4 Get Is Retry-Based, Not Event-Driven + +The receiver side polls the store with a short retry loop. This is simple and robust, but it means the connector is latency-sensitive to: + +- store visibility delay +- network jitter +- payload size + +If the payload is not visible within the retry window, the connector reports a timeout. + +### 11. Summary + +`MooncakeStoreConnector` is the remote, store-based connector in the OmniConnector stack. Its design is straightforward: + +- serialize the payload +- store it in Mooncake under a stage-qualified key +- fetch it by the same key on the receiver side +- deserialize it back into the original object + +This connector fills an important gap in the system: + +1. It enables cross-node transfer without requiring shared memory. +2. It keeps the stage communication model uniform. +3. It provides a simpler operational alternative to the transfer-engine-based remote connector. + +Its simplicity is also its main trade-off: it favors a clean object-transfer model over the fast-path and peer-to-peer optimizations implemented by `MooncakeTransferEngineConnector`. diff --git a/docs/design/feature/omni_connectors/mooncake_transfer_engine_connector.md b/docs/design/feature/omni_connectors/mooncake_transfer_engine_connector.md index 96f93ef9a..798644b96 100644 --- a/docs/design/feature/omni_connectors/mooncake_transfer_engine_connector.md +++ b/docs/design/feature/omni_connectors/mooncake_transfer_engine_connector.md @@ -205,3 +205,589 @@ for test-specific setup instructions. For more details on the underlying engine, refer to the [Mooncake repository](https://github.com/kvcache-ai/Mooncake). + +--- + +## Design + +### 1. Overview + +`MooncakeTransferEngineConnector` is the high-performance remote connector in `vllm_omni/distributed/omni_connectors`. It is built on top of Mooncake `TransferEngine` and combines: + +- a **direct data plane** for remote memory writes +- a **ZMQ side channel** for metadata lookup, handshake, and completion signaling +- a **managed local memory pool** for both send and receive buffers + +Unlike `MooncakeStoreConnector`, which treats the backend as a distributed store, `MooncakeTransferEngineConnector` is designed as a peer-to-peer transport. Its goal is to move large stage payloads efficiently while still fitting the common `put()` / `get()` API defined by `OmniConnectorBase`. + +It is the most performance-oriented connector in the current OmniConnector family and is intended for large remote payloads such as: + +- KV cache transfer +- stage hidden-state payloads +- streaming chunk payloads +- other binary-heavy inter-stage artifacts + +### 2. Relationship with the OmniConnector System + +`MooncakeTransferEngineConnector` implements the same connector contract as the other backends: + +- `put(from_stage, to_stage, put_key, data)` +- `get(from_stage, to_stage, get_key, metadata=None)` +- `cleanup(request_id, ...)` +- `health()` +- `close()` + +It is integrated into the system through the standard connector plumbing: + +- `OmniConnectorFactory` constructs the connector from `ConnectorSpec` +- `load_omni_transfer_config()` resolves the edge-level connector configuration +- `get_connectors_config_for_stage()` and `resolve_omni_kv_config_for_stage()` inject the connector role +- All callers (batch forwarding, chunk transfer, KV transfer, etc.) interact with it through the same `put()` / `get()` contract + +The key system-level distinction is that this connector is **role-aware**: + +- sender instances expose data and listen for pull requests +- receiver instances allocate buffers and actively pull data from the sender + +### 3. Design Goals + +The connector is designed around four primary goals: + +1. **High-throughput remote transfer** + Avoid store-mediated round trips and write directly into the receiver memory region. + +2. **Fast path for raw payloads** + Support `torch.Tensor`, `bytes`, and `ManagedBuffer` without forcing all traffic through full object serialization. + +3. **Unified connector abstraction** + Preserve the same `put()` / `get()` interface used by the rest of the OmniConnector stack. + +4. **Safe lifecycle management** + Manage allocation, reuse, cleanup, and failure recovery for a registered memory pool. + +### 4. Architecture Overview + +At a high level, the connector is composed of four main subsystems: + +```mermaid +classDiagram + class OmniConnectorBase { + <> + +put(from_stage, to_stage, put_key, data) + +get(from_stage, to_stage, get_key, metadata) + +cleanup(request_id) + +health() + +close() + } + + class MooncakeTransferEngineConnector { + +supports_raw_data: bool + -engine: TransferEngine + -allocator: BufferAllocator + -pool: torch.Tensor + -zmq_ctx: zmq.Context + -_local_buffers: dict + -_sender_executor: ThreadPoolExecutor + -_listener_thread: threading.Thread + +put(...) + +get(...) + +update_sender_info(sender_host, sender_zmq_port) + +get_connection_info() + +cleanup(request_id, from_stage, to_stage) + +close() + } + + class BufferAllocator { + -total_size: int + -alignment: int + -free_blocks: list + +alloc(size) int + +free(offset, size) + } + + class ManagedBuffer { + -allocator: BufferAllocator + -offset: int + -size: int + -pool_tensor: torch.Tensor + +tensor + +as_tensor(dtype, shape) torch.Tensor + +to_bytes() bytes + +release() + } + + class TransferEngine { + +initialize(host, handshake, protocol, device_name) + +register_memory(base_ptr, size) + +batch_transfer_sync_write(remote_session, src_addrs, dst_addrs, lengths) + +unregister_memory(base_ptr) + +get_rpc_port() int + } + + class QueryRequest { + +request_id: str + } + + class QueryResponse { + +request_id: str + +data_size: int + +is_fast_path: bool + } + + class MooncakeAgentMetadata { + +remote_hostname: str + +remote_port: int + +request_id: str + +dst_addrs: list[int] + +lengths: list[int] + } + + OmniConnectorBase <|-- MooncakeTransferEngineConnector + MooncakeTransferEngineConnector *-- BufferAllocator + MooncakeTransferEngineConnector *-- TransferEngine + ManagedBuffer --> BufferAllocator : releases to + ManagedBuffer --> "1" torch.Tensor : views + MooncakeTransferEngineConnector ..> ManagedBuffer : returns / retains + MooncakeTransferEngineConnector ..> QueryRequest : decodes + MooncakeTransferEngineConnector ..> QueryResponse : encodes + MooncakeTransferEngineConnector ..> MooncakeAgentMetadata : exchanges +``` + +#### 4.1 Transfer Engine + +Mooncake `TransferEngine` is responsible for the actual data-plane transfer. It registers local memory and performs synchronous remote writes through: + +```python +batch_transfer_sync_write(...) +``` + +#### 4.2 Managed Memory Pool + +Each connector instance owns a large pre-registered memory pool: + +- CPU pinned memory when `memory_pool_device == "cpu"` +- GPU memory when `memory_pool_device == "cuda"` + +This avoids repeated memory registration and allows each transfer to allocate subranges from one long-lived pool. + +#### 4.3 Buffer Manager + +Two helper classes control local memory ownership: + +- `BufferAllocator` + Manages aligned subrange allocation and free-list merging. + +- `ManagedBuffer` + Represents one live slice of the pool and exposes: + - `.tensor` + - `.as_tensor(dtype, shape)` + - `.to_bytes()` + - `.release()` + +#### 4.4 ZMQ Side Channel + +ZMQ is used for transport coordination, not for the data payload itself. It handles: + +- metadata query from receiver to sender +- pull request submission +- completion or error signaling +- internal notification from worker threads back to the listener thread + +This split makes the control plane lightweight while keeping the bulk payload on the transfer engine data plane. + +### 5. Role Model + +#### 5.1 Sender Role + +A sender connector: + +- accepts `put()` calls +- stores live transfer-ready buffers in `_local_buffers` +- starts a ZMQ listener thread +- responds to metadata queries and pull requests from receivers + +#### 5.2 Receiver Role + +A receiver connector: + +- does not bind the sender-side ZMQ listener +- accepts `get()` calls +- allocates receive buffers from its own pool +- requests metadata or transfer service from the sender + +The role is not inferred dynamically. It is injected by the stage configuration layer: + +- incoming edge for a stage -> `role="receiver"` +- outgoing edge for a stage -> `role="sender"` + +This is important because incorrect role assignment would break initialization semantics. + +#### 5.3 Host Auto-Detection + +The `host` configuration field supports the special value `"auto"`. When set, the connector auto-detects the local IP address that would be used for external communication (via a UDP socket probe to `8.8.8.8`). If that fails, it falls back to hostname resolution, and ultimately to `127.0.0.1`. + +This is useful in environments where the operator does not want to hard-code IP addresses in the connector config. + +#### 5.4 RDMA Device Filtering + +The `device_name` configuration field allows the operator to specify which RDMA NICs to use (comma-separated, e.g. `"mlx5_0,mlx5_1"`). If not set in config, the connector also checks the `RDMA_DEVICE_NAME` environment variable. + +This is important in environments with mixed InfiniBand/RoCE NICs, where not all devices are suitable for the transfer engine. + +### 6. Local Memory Management + +#### 6.1 Memory Pool Registration + +During initialization, the connector: + +1. allocates a large pool tensor +2. records its base pointer +3. registers that memory with Mooncake +4. creates a `BufferAllocator` for subrange management + +This means every later transfer only allocates offsets inside the pre-registered pool rather than registering memory per request. + +#### 6.2 BufferAllocator + +`BufferAllocator` maintains a sorted free list of `(offset, size)` blocks and enforces alignment. Its responsibilities include: + +- aligned allocation +- freeing previously allocated blocks +- adjacent block merging +- double-free detection +- overlap detection to catch corruption + +This is a critical piece of the connector because both sender and receiver depend on long-lived pool reuse. + +#### 6.3 ManagedBuffer + +`ManagedBuffer` is the main fast-path data wrapper. It can: + +- expose the pool slice as a zero-copy 1D `uint8` tensor +- reinterpret that slice as a typed tensor +- copy out the contents as Python `bytes` +- release the slice back to the allocator + +The connector uses `ManagedBuffer` in two different ways: + +- as a send-side holder to keep the pool slice alive +- as a receive-side return type when `is_fast_path=True` + +### 7. Put Flow + +#### 7.1 High-Level Behavior + +`put()` is only valid in sender mode. Its job is to expose a payload for later remote pull by the receiver. + +The high-level flow is: + +1. validate connector state and role +2. convert the input into a pool-backed transferable representation +3. store the transfer metadata in `_local_buffers` +4. return lightweight metadata describing how the receiver can fetch the data + +#### 7.2 Payload Type Handling + +`put()` supports three payload classes: + +**A. `ManagedBuffer`** + +If the buffer belongs to the same pool, the connector can use it directly without copying. This is the most efficient path. + +If the buffer comes from a different pool, the connector falls back to a copy path. + +**B. `torch.Tensor` or `bytes`** + +These payloads are copied into the local pool and marked as fast-path data: + +- no Omni object serialization is required +- receiver can get a `ManagedBuffer` back + +**C. Generic Python object** + +Any other payload is serialized via `OmniSerializer.serialize(...)` and then copied into the pool. + +In this case: + +- `is_fast_path=False` +- the receiver will deserialize back into a Python object + +#### 7.3 Sender Metadata + +The sender returns: + +```python +{ + "source_host": self.host, + "source_port": self.zmq_port, + "data_size": size, + "is_fast_path": is_fast_path, +} +``` + +This metadata is intentionally lightweight. It tells the receiver: + +- where the sender-side control plane lives +- how large the remote transfer will be +- whether the payload should be returned as a `ManagedBuffer` or a deserialized object + +#### 7.4 Sender Buffer Table + +The sender stores each live payload in `_local_buffers` under the stage-qualified key. Each entry contains: + +- source addresses +- lengths +- the holder object +- ownership information (`should_release`) +- `is_fast_path` +- creation time + +This table is the sender-side truth source for both metadata queries and pull requests. + +### 8. Get Flow + +#### 8.1 High-Level Behavior + +`get()` runs on the receiver side and performs four steps: + +1. resolve metadata +2. allocate a destination buffer in the local pool +3. request the sender to write into that destination buffer +4. return either a `ManagedBuffer` or a deserialized object + +#### 8.2 Metadata Resolution Paths + +The `metadata` parameter in `get()` is optional. The connector supports two resolution modes depending on whether the caller supplies it. + +**With metadata** + +When the caller passes metadata, the connector uses it directly. The metadata carries: + +- `source_host` / `source_port` — sender ZMQ endpoint +- `data_size` — payload byte count +- `is_fast_path` — whether the receiver gets a `ManagedBuffer` or a deserialized object + +This mode is suitable when the control plane already forwards the sender's `put()` output to the receiver. + +**Without metadata** + +When `get(metadata=None)` is called, the connector queries the sender over ZMQ to discover the same fields (`data_size`, `is_fast_path`). The caller must first call: + +```python +update_sender_info(sender_host, sender_zmq_port) +``` + +so that the connector knows where to send the query. + +This mode is suitable for polling-based flows (e.g. KV transfer, async chunk transfer) where the receiver does not have metadata from the control plane. + +#### 8.3 Destination Allocation + +Once metadata is resolved, the receiver: + +1. allocates a subrange from its own local pool +2. wraps it in a `ManagedBuffer` +3. builds a `MooncakeAgentMetadata` request containing: + - receiver hostname + - receiver RPC port + - request ID + - destination addresses + - transfer lengths + +This tells the sender exactly where to write the incoming data. + +#### 8.4 Transfer Completion + +The receiver then sends the pull request over ZMQ and waits for: + +- `TRANS_DONE` +- or `TRANS_ERROR` + +If the transfer succeeds: + +- for `is_fast_path=True`, the receiver returns `(ManagedBuffer, size)` +- for `is_fast_path=False`, the receiver copies to bytes, deserializes, releases the buffer, and returns `(object, size)` + +### 9. Sender-Side Listener Design + +#### 9.1 Listener Thread + +In sender mode, the connector starts `_zmq_listener_loop()` after initialization. The listener: + +- binds `tcp://{host}:{zmq_port}` +- receives incoming requests +- uses a poller for socket events and internal notifications +- periodically reclaims stale buffers + +If the bind fails, initialization fails immediately. The code does not silently downgrade the connector role. + +#### 9.2 Worker Thread Pool + +The listener hands work to `_sender_executor` so that the listener thread does not block on transfer work. + +There are two request types: + +- metadata query -> `_handle_query_request(...)` +- transfer request -> `_handle_pull_request(...)` + +#### 9.3 Query Handling + +For metadata queries, the sender looks up the request ID in `_local_buffers` and returns: + +- data size +- fast-path flag + +This supports consumers that only know the sender endpoint but not the original sender metadata. + +#### 9.4 Pull Handling + +For a transfer request, the sender: + +1. locates the source addresses in `_local_buffers` +2. constructs the remote session identifier +3. calls `batch_transfer_sync_write(...)` +4. replies `TRANS_DONE` or `TRANS_ERROR` + +On success, the sender immediately calls `cleanup(meta.request_id)` and frees the producer-side buffer if it owns it. + +This choice is important: it makes the connector effectively a **single-consumer transfer model** for each successful put/get pair. + +### 10. Fast Path Semantics + +This connector explicitly advertises: + +```python +supports_raw_data = True +``` + +That means it can move raw payloads without forcing everything through the Omni object serializer. + +#### Fast Path + +For `torch.Tensor`, `bytes`, or pool-local `ManagedBuffer`: + +- sender returns `is_fast_path=True` +- receiver returns a `ManagedBuffer` +- caller is responsible for calling `release()` + +This avoids an unnecessary copy on the receiver side. + +#### Serialized Object Path + +For arbitrary Python objects: + +- sender serializes the payload +- receiver converts the receive buffer to bytes +- receiver deserializes the object +- receive buffer is released internally + +This preserves a uniform object-oriented API while still allowing optimized raw-data transport when possible. + +### 11. Failure Handling and Cleanup + +#### 11.1 Timeouts and Socket Recovery + +The receiver caches ZMQ REQ sockets per thread, but invalidates them after failures. This avoids reusing sockets that may be stuck in a bad state after timeout or receive errors. + +Timeout is scaled based on payload size: + +- a base timeout +- plus additional time for large payloads + +This is intended to reduce false timeouts for large remote writes. + +#### 11.2 Stale Buffer Reclamation + +The sender periodically reclaims old entries from `_local_buffers` using a TTL policy. This protects the memory pool from permanent leaks if a receiver crashes or never consumes a prepared payload. + +This is a practical recovery mechanism, although the code notes that TTL cleanup can still race with very long-running in-flight transfers. + +#### 11.3 Connector Shutdown + +`close()` is a full resource teardown routine. It: + +- stops the listener thread +- shuts down the worker executor +- releases all pending buffers +- closes cached sockets +- unregisters memory from the engine when supported +- terminates the ZMQ context +- drops the pool reference + +This makes `MooncakeTransferEngineConnector` the most lifecycle-aware connector in the current connector family. + +### 12. Current Implementation Constraints + +The current code documents several important topology constraints. + +#### 12.1 One Sender to One Receiver per Successful Transfer + +After a successful transfer, the sender-side buffer is cleaned up immediately. This means the same prepared payload is not retained for multiple independent receivers. + +#### 12.2 One Receiver to One Active Sender Endpoint + +The receiver only stores one `(sender_host, sender_zmq_port)` pair through `update_sender_info(...)`. So the metadata-query mode is currently single-sender at a time. + +#### 12.3 Explicit Buffer Ownership Matters + +When the connector allocates a pool slice internally, it is responsible for releasing it. When a caller passes an externally owned `ManagedBuffer`, the connector keeps it alive for transfer but does not assume ownership of its eventual release. + +These constraints are consistent with the current implementation and should be treated as design assumptions rather than incidental behavior. + +### 13. Data Flow in the Pipeline + +The end-to-end sender/receiver interaction is: + +```mermaid +sequenceDiagram + participant SenderStage + participant SenderConnector + participant ReceiverConnector + participant ReceiverStage + + SenderStage->>SenderConnector: put(from_stage, to_stage, put_key, data) + SenderConnector->>SenderConnector: place payload in local memory pool + SenderConnector-->>SenderStage: metadata(source_host, source_port, data_size, is_fast_path) + + ReceiverStage->>ReceiverConnector: get(..., metadata) + ReceiverConnector->>ReceiverConnector: allocate destination buffer + ReceiverConnector->>SenderConnector: ZMQ pull request with dst addr + SenderConnector->>ReceiverConnector: TransferEngine remote write + SenderConnector-->>ReceiverConnector: TRANS_DONE + ReceiverConnector-->>ReceiverStage: ManagedBuffer or deserialized object +``` + +For metadata-less polling, the flow simply adds a metadata query step before the pull request. + +### 14. Strengths and Trade-offs + +#### Strengths + +- Best remote-transfer design in the current connector stack for large payloads. +- Supports raw-data fast path. +- Keeps stage communication under the same connector abstraction. +- Includes real lifecycle and memory-pool management. +- Works for both stage payload transfer and KV transfer scenarios. + +#### Trade-offs + +- More complex than the store-based connector. +- Correctness depends on role injection and endpoint coordination. +- Caller must release fast-path receive buffers. +- Current implementation is optimized for single-consumer transfer semantics. + +### 15. Summary + +`MooncakeTransferEngineConnector` is the high-performance peer-to-peer transport in the OmniConnector system. Its design combines: + +- a registered memory pool +- a safe subrange allocator +- a ZMQ control plane +- a Mooncake transfer-engine data plane + +This allows the connector to support both: + +1. a **fast path** for raw tensors and bytes +2. a **generic object path** for arbitrary Python payloads + +Within vLLM-Omni, it is the connector that most directly targets performance-sensitive remote transfer, especially for large payloads and KV cache movement. Its additional complexity is deliberate: it is the connector that turns the generic OmniConnector abstraction into a transport capable of efficient remote memory movement rather than simple object storage. diff --git a/docs/design/feature/omni_connectors/shared_memory_connector.md b/docs/design/feature/omni_connectors/shared_memory_connector.md index eb91a889a..5b35014f2 100644 --- a/docs/design/feature/omni_connectors/shared_memory_connector.md +++ b/docs/design/feature/omni_connectors/shared_memory_connector.md @@ -7,9 +7,10 @@ auto-configured when no explicit connector is specified for an edge. ## How It Works -- Small payloads (< threshold): serialized and passed inline in metadata. -- Large payloads (>= threshold): stored in shared memory; the SHM name is - returned in metadata. +All payloads are serialized and stored in shared memory (`/dev/shm`); the SHM +segment name is returned in metadata. The configuration exposes a +`shm_threshold_bytes` field for a future inline-vs-SHM split, but the current +implementation always uses shared memory regardless of payload size. ## Configuration @@ -25,3 +26,234 @@ runtime: ## Notes - Auto-mode uses SharedMemoryConnector if no connector is declared for an edge. + +--- + +## Design + +### 1. Overview + +`SharedMemoryConnector` is the default same-node connector in `vllm_omni/distributed/omni_connectors`. It is designed for stage-to-stage transfer when producer and consumer processes run on the same host and can share `/dev/shm`. + +The connector provides a unified `put()` / `get()` API for arbitrary Python objects while keeping the control plane lightweight: + +- The payload is serialized by the connector. +- The serialized bytes are placed in shared memory. +- The queue/control plane only carries a small metadata handle. + +This makes `SharedMemoryConnector` the simplest connector in the OmniConnector family and the default fallback when an edge does not explicitly configure another backend. + +### 2. Relationship with the OmniConnector System + +`SharedMemoryConnector` implements `OmniConnectorBase`, so it follows the same lifecycle and API contract as the other connectors: + +- `put(from_stage, to_stage, put_key, data)` +- `get(from_stage, to_stage, get_key, metadata=None)` +- `cleanup(request_id)` +- `health()` +- `close()` + +Within the larger system: + +- `load_omni_transfer_config()` automatically fills missing edges with `SharedMemoryConnector`. +- Callers interact with the connector exclusively through the `put()` / `get()` / `cleanup()` contract — the connector does not require caller-specific logic. + +Compared with the remote Mooncake-based connectors, `SharedMemoryConnector` is intentionally minimal and local-only. + +### 3. Design Goals + +The connector is built around the following goals: + +- **Low-friction local transfer** for single-node multi-process pipelines. +- **Unified object semantics** for arbitrary Python payloads. +- **Small control-plane overhead** by passing only metadata through queues. +- **Zero external dependencies** beyond Python shared memory and the existing stage utilities. + +It is not intended to provide cross-node transfer, RDMA, or raw tensor zero-copy semantics across processes. + +### 4. Core Design + +#### 4.1 Serialization Model + +`SharedMemoryConnector` always starts from a Python object and serializes it through the shared Omni serializer: + +```python +payload = self.serialize_obj(data) +``` + +This keeps the connector behavior consistent with the rest of the connector stack: + +- producer code does not need connector-specific serialization logic +- consumer code always receives the original object after deserialization +- the connector can reuse the same serializer used by other backends + +#### 4.2 Shared Memory as the Data Plane + +The actual data plane is a shared-memory segment created by: + +- `shm_write_bytes(...)` +- `shm_read_bytes(...)` + +The connector stores a small metadata object such as: + +```python +{ + "shm": {"name": ..., "size": ...}, + "size": ... +} +``` + +This metadata is passed over the control plane and allows the downstream stage to locate the shared-memory segment. + +#### 4.3 Locking Model + +To avoid races between the producer and consumer, the connector uses a lock file per request: + +```text +/dev/shm/shm_{put_key}_lockfile.lock +``` + +Locking is done with `fcntl.flock`: + +- producer uses `LOCK_EX` +- consumer uses `LOCK_EX` + +Both sides acquire an exclusive lock. This ensures that the shared-memory segment is not read while it is still being written and makes the handoff safer in a multi-process environment. + +### 5. Put / Get Flow + +#### 5.1 Producer Flow: `put()` + +The producer-side flow is: + +1. Serialize the input object to bytes. +2. Compute the payload size. +3. Acquire the per-request lock file. +4. Write the bytes into shared memory. +5. Return lightweight metadata to the caller. + +The returned tuple is: + +```python +(success, serialized_size, metadata) +``` + +where `metadata` contains the shared-memory handle needed by the consumer. + +#### 5.2 Consumer Flow: `get(metadata=...)` + +The primary consumer path is metadata-driven: + +1. Extract the shared-memory handle from `metadata`. +2. Acquire the exclusive lock. +3. Read the raw bytes from shared memory. +4. Deserialize the bytes back into the original Python object. +5. Remove the lock file if it still exists. + +This is the path used by the current stage-to-stage connector flow. + +#### 5.3 Compatibility Flow: `get(metadata=None)` + +The connector also keeps a compatibility path for callers that only know the key: + +1. Attempt to open the shared-memory segment by name via `SharedMemory(name=get_key)`. +2. If the segment exists and has non-zero size, acquire the exclusive lock and read the bytes. +3. Deserialize the bytes and return the object. + +If the segment does not exist or any exception occurs, the call returns `None` immediately. There is no retry loop in this path -- it is a single-attempt open. + +This path is mainly for older code paths and is not the preferred mode for the current connector pipeline. + +### 6. Key Implementation Characteristics + +#### 6.1 Threshold Exists, but the Current Code Always Uses SHM + +The class keeps a `shm_threshold_bytes` field and still exposes metrics for inline writes. However, the current implementation uses: + +```python +if True: + ... +``` + +inside `put()`, which means the current code path always writes to shared memory. + +So the design still suggests a future split between: + +- small payloads inline +- large payloads in shared memory + +but the current behavior is effectively: + +- all payloads go through shared memory + +This should be documented because it affects real runtime behavior. + +#### 6.2 Cleanup Is Currently Passive + +`cleanup()` is currently a no-op. The intended assumption is: + +- the consumer reads the segment +- the underlying shared-memory helpers unlink it + +If the consumer never executes `get()`, the shared-memory segment may remain allocated. This means the connector relies on the normal success path for resource reclamation. + +#### 6.3 Close Is Currently Minimal + +`close()` is also a no-op. There is no connector-owned background thread, socket, or memory pool to tear down, so the lifecycle is simple. The trade-off is that `close()` does not scan or recover leaked shared-memory resources. + +### 7. Data Flow in the Pipeline + +The typical flow with `SharedMemoryConnector` is: + +```mermaid +sequenceDiagram + participant SenderStage + participant SharedMemoryConnector + participant QueueOrControlPlane + participant ReceiverStage + + SenderStage->>SharedMemoryConnector: put(from_stage, to_stage, put_key, data) + SharedMemoryConnector->>SharedMemoryConnector: serialize object + SharedMemoryConnector->>SharedMemoryConnector: write bytes to /dev/shm + SharedMemoryConnector-->>SenderStage: metadata {shm, size} + SenderStage->>QueueOrControlPlane: forward connector metadata + QueueOrControlPlane->>ReceiverStage: task + connector metadata + ReceiverStage->>SharedMemoryConnector: get(from_stage, to_stage, get_key, metadata) + SharedMemoryConnector->>SharedMemoryConnector: read bytes from /dev/shm + SharedMemoryConnector->>SharedMemoryConnector: deserialize object + SharedMemoryConnector-->>ReceiverStage: (data, size) +``` + +This is a classic split-control-plane / data-plane design, but constrained to a single host. + +### 8. Strengths and Trade-offs + +#### Strengths + +- Very simple deployment model. +- No external service dependency. +- Fits naturally into the existing queue-driven orchestration flow. +- Good default for local multi-process pipelines. + +#### Trade-offs + +- Same-node only. +- Full object serialization and deserialization are still required. +- Resource cleanup depends on the normal consumer path. +- Shared memory capacity is limited by host configuration. + +### 9. Summary + +`SharedMemoryConnector` is the baseline local transport for the OmniConnector system. Its design is intentionally straightforward: + +- serialize object +- place bytes in shared memory +- pass metadata through the control plane +- deserialize on the receiving side + +It plays two important roles in vLLM-Omni: + +1. It is the simplest production-ready connector for same-node stage pipelines. +2. It serves as the automatic fallback connector when no explicit edge transport is configured. + +Although the current implementation is deliberately minimal, it provides the foundation for reliable local connector semantics and keeps the stage communication model uniform across the system. diff --git a/docs/design/feature/omni_connectors/yuanrong_connector.md b/docs/design/feature/omni_connectors/yuanrong_connector.md index 88989d31a..12127ba3e 100644 --- a/docs/design/feature/omni_connectors/yuanrong_connector.md +++ b/docs/design/feature/omni_connectors/yuanrong_connector.md @@ -94,8 +94,265 @@ stage_args: Parameters: - host: datasystem worker host. -- port: datasystem worker port. +- port: datasystem worker port (default: `35001` if omitted; the example above uses `31501` to match the worker startup command). - get_sub_timeout_ms: get timeout in milliseconds (0 for no timeout). For more details, refer to the [Yuanrong Datasystem repository](https://atomgit.com/openeuler/yuanrong-datasystem). + +--- + +## Design + +### 1. Overview + +`YuanrongConnector` is the Datasystem-based remote connector in `vllm_omni/distributed/omni_connectors`. It uses Yuanrong Datasystem's distributed key-value client as the transport backend and exposes the same `put()` / `get()` interface as the other OmniConnectors. + +Like `MooncakeStoreConnector`, it is a store-oriented remote connector rather than a direct peer-to-peer transport. Its role is to let stage payloads move across nodes through a deterministic key-based storage abstraction while keeping the rest of the pipeline on the common connector API. + +It is intended for deployments that already use Yuanrong Datasystem and want a remote connector that integrates with the existing OmniConnector configuration and orchestration model. + +### 2. Relationship with the OmniConnector System + +`YuanrongConnector` implements `OmniConnectorBase`, so it participates in the same connector lifecycle as the other backends: + +- `OmniConnectorFactory` constructs it from a `ConnectorSpec` +- stage edge configuration is resolved by `load_omni_transfer_config()` +- All callers (batch forwarding, chunk transfer, KV transfer, etc.) interact with it through the same `put()` / `get()` contract + +This means the connector is not exposed directly to stage logic. Stages only interact with the generic connector contract, and the backend choice remains a configuration concern. + +### 3. Design Goals + +The connector is built around the following goals: + +1. **Cross-node payload transfer through Datasystem** + Reuse Yuanrong Datasystem as the remote exchange medium for stage data. + +2. **Uniform object transfer semantics** + Allow arbitrary Python objects to be transmitted through the shared Omni serializer. + +3. **Minimal connector-specific control plane** + Use deterministic keys so that consumers can retrieve data without an extra transport metadata handoff. + +4. **Operational reuse of existing infrastructure** + Fit into environments that already deploy Yuanrong Datasystem workers and etcd. + +The connector is not designed for direct remote-memory writes or tensor-specific fast-path transfer. + +### 4. Core Design + +#### 4.1 Store-Oriented Transfer Model + +`YuanrongConnector` treats the transport backend as a distributed object store: + +1. serialize the Python payload +2. build a deterministic connector key +3. write the serialized bytes into Datasystem +4. read the bytes back on the receiver side +5. deserialize them into the original object + +This is the same broad architectural class as `MooncakeStoreConnector`, but implemented on top of Yuanrong Datasystem APIs instead of Mooncake store APIs. + +#### 4.2 Deterministic Keying + +Unlike the default `_make_key()` in `OmniConnectorBase`, `YuanrongConnector` defines its own key format: + +```text +{request_id}:{from_stage}_{to_stage} +``` + +This has two design implications: + +- the request identifier remains the primary lookup handle +- stage routing information is embedded in the key so that the same logical request ID can safely appear on different edges + +The explicit override also makes the key format easier to align with Datasystem-side debugging and operational inspection. + +#### 4.3 No Extra Metadata Hand-off + +`put()` returns: + +```python +(success, serialized_size, None) +``` + +and does not generate connector-specific metadata. + +This design works because the receiver can reconstruct the exact same key from: + +- `get_key` +- `from_stage` +- `to_stage` + +As a result, the connector does not require a separate side-channel metadata handoff. + +### 5. Initialization + +#### 5.1 Datasystem Client Dependency + +The connector requires the Datasystem Python bindings to expose: + +- `KVClient` +- `SetParam` +- `WriteMode` + +If any of these symbols are unavailable, connector construction fails immediately with `ImportError`. This keeps configuration errors explicit and avoids a partially initialized runtime. + +#### 5.2 Client Setup + +During `_init_client()`, the connector: + +1. reads `host` and `port` +2. creates `KVClient(host, port)` +3. calls `client.init()` + +At construction time it also creates a `SetParam` and fixes: + +```python +self.set_param.write_mode = WriteMode.NONE_L2_CACHE_EVICT +``` + +This means the connector has a stable write policy for all writes and does not currently expose write-mode selection as a higher-level connector option. + +### 6. Put / Get Flow + +#### 6.1 Producer Flow: `put()` + +The producer-side flow is: + +1. verify that the Datasystem client has been initialized +2. serialize the input object with the shared Omni serializer +3. build the Datasystem key using the connector-specific `_make_key()` +4. call `client.set(key, serialized_data, self.set_param.write_mode)` +5. update metrics and return success + +The returned metadata is always `None`, because the Datasystem key itself is the lookup contract between producer and consumer. + +#### 6.2 Consumer Flow: `get()` + +The consumer-side flow is: + +1. verify that the Datasystem client has been initialized +2. rebuild the same key with `from_stage`, `to_stage`, and `get_key` +3. call: + +```python +client.get([key], False, self.get_sub_timeout_ms) +``` + +4. take the first returned element if present +5. deserialize it and return `(data, payload_size)` + +If the returned list is empty or contains no data for the key, `get()` returns `None`. + +### 7. Timeout and Retrieval Semantics + +The connector uses `get_sub_timeout_ms` as its read timeout. Unlike `MooncakeStoreConnector`, which performs an explicit retry loop in Python, `YuanrongConnector` delegates waiting behavior more directly to the Datasystem client call. + +This leads to a slightly different retrieval model: + +- `MooncakeStoreConnector`: retry-oriented polling in connector code +- `YuanrongConnector`: single client call with backend-managed timeout behavior + +From the connector API perspective the result is the same, but operationally the waiting behavior is more dependent on Datasystem client semantics. + +### 8. Integration with Stage Communication + +All callers use the connector through the same `put()` / `get()` contract: + +- the sender calls `put()` to serialize and store the payload +- the receiver calls `get()` to retrieve and deserialize it +- no connector-specific metadata is required, since the Datasystem key is the rendezvous point + +Because `put()` returns `metadata=None`, the connector is naturally compatible with callers that do not forward metadata (e.g. polling-based flows). The trade-off is that all payloads incur full serialization and deserialization costs, and there is no raw tensor fast path, which makes the connector functional but not optimized for the largest payloads. + +### 9. Data Flow in the Pipeline + +The end-to-end transfer model is: + +```mermaid +sequenceDiagram + participant SenderStage + participant YuanrongConnector + participant Datasystem + participant ReceiverStage + + SenderStage->>YuanrongConnector: put(from_stage, to_stage, put_key, data) + YuanrongConnector->>YuanrongConnector: serialize object + YuanrongConnector->>Datasystem: set(key, bytes) + YuanrongConnector-->>SenderStage: (success, size, None) + + ReceiverStage->>YuanrongConnector: get(from_stage, to_stage, get_key) + YuanrongConnector->>Datasystem: get([key], timeout) + YuanrongConnector->>YuanrongConnector: deserialize bytes + YuanrongConnector-->>ReceiverStage: (data, size) +``` + +This is a store-mediated remote connector design with deterministic key lookup and no explicit side-channel metadata exchange. + +### 10. Strengths and Trade-offs + +#### Strengths + +- Reuses existing Yuanrong Datasystem infrastructure. +- Keeps the connector contract simple and uniform. +- Requires no connector-specific metadata handoff. +- Suitable for remote stage transfer in Datasystem-based deployments. + +#### Trade-offs + +- Always pays full serialization and deserialization cost. +- Does not support raw bytes / tensor fast-path semantics. +- Depends on external Datasystem worker availability. +- Retrieval and timeout behavior are partly delegated to the backend client. + +### 11. Important Implementation Characteristics + +#### 11.1 Cleanup Is a No-op + +`cleanup()` only logs a debug message and does not explicitly remove data from Datasystem. The current design assumes backend-side lifecycle or garbage collection rather than request-scoped delete semantics inside the connector. + +This mirrors the same design trade-off seen in other store-based connectors: simplicity over explicit per-request data reclamation. + +#### 11.2 Close Only Releases the Local Client Handle + +`close()` does not perform a remote shutdown. It simply clears the local `client` reference and marks the connector as closed from the process perspective: + +```python +self.client = None +``` + +This is appropriate for a client-based store connector, but it also means that backend resource lifecycle remains outside the connector's control. + +#### 11.3 Error and Timeout Metrics Are Coarse-Grained + +The connector tracks: + +- `puts` +- `gets` +- `bytes_transferred` +- `errors` +- `timeouts` + +In the current code, failed `put()` increments `errors`, while `get()` exceptions increment `timeouts`. This is operationally useful, but it does not distinguish between: + +- backend timeout +- not-found result +- transport failure +- deserialization failure + +So the metrics should be read as high-level health indicators, not detailed root-cause diagnostics. + +### 12. Summary + +`YuanrongConnector` is the Datasystem-backed remote connector in the OmniConnector family. Its design is straightforward: + +- serialize payloads +- store them under a deterministic stage-qualified key +- retrieve them by the same key +- deserialize them on the receiving side + +Within vLLM-Omni, it provides a clean integration point for Yuanrong Datasystem-based deployments while preserving the same connector abstraction used by the rest of the pipeline. + +Its design priorities are simplicity, infrastructure reuse, and API consistency rather than direct-memory transport optimization. From 0540af327ed2c72f41ee7dd9d111d4a042ac2d3b Mon Sep 17 00:00:00 2001 From: dsinghvi Date: Fri, 13 Mar 2026 02:34:29 +0000 Subject: [PATCH 37/38] [BugFix] Readme and example runner file for cosyvoice3 missed in refactoring (#1685) Signed-off-by: dsinghvi Signed-off-by: Divyansh Singhvi Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com> --- docs/.nav.yml | 1 + .../examples/offline_inference/cosyvoice3.md | 74 +++++++ .../offline_inference/cosyvoice3/README.md | 64 ++++++ .../cosyvoice3/verify_e2e_cosyvoice.py | 197 ++++++++++++++++++ 4 files changed, 336 insertions(+) create mode 100644 docs/user_guide/examples/offline_inference/cosyvoice3.md create mode 100644 examples/offline_inference/cosyvoice3/README.md create mode 100644 examples/offline_inference/cosyvoice3/verify_e2e_cosyvoice.py diff --git a/docs/.nav.yml b/docs/.nav.yml index b9f39ebfa..eb135c291 100644 --- a/docs/.nav.yml +++ b/docs/.nav.yml @@ -13,6 +13,7 @@ nav: - examples/README.md - Offline Inference: - BAGEL-7B-MoT: user_guide/examples/offline_inference/bagel.md + - CosyVoice3: user_guide/examples/offline_inference/cosyvoice3.md - Fish Speech S2 Pro: user_guide/examples/offline_inference/fish_speech.md - GLM-Image Multistage End-to-End Inference: user_guide/examples/offline_inference/glm_image.md - Helios Video Generation: user_guide/examples/offline_inference/helios.md diff --git a/docs/user_guide/examples/offline_inference/cosyvoice3.md b/docs/user_guide/examples/offline_inference/cosyvoice3.md new file mode 100644 index 000000000..d912f1c62 --- /dev/null +++ b/docs/user_guide/examples/offline_inference/cosyvoice3.md @@ -0,0 +1,74 @@ +# CosyVoice3 + +Source . + + +## Setup + +Install dependencies: +``` +uv pip install -e . +``` + +> **Note:** This includes required libraries such as `librosa`, `soundfile`, +> `onnxruntime`, `x-transformers`, and `einops` via +> `requirements/common.txt` and platform-specific requirements files. + +Download the model snapshot: +``` +from huggingface_hub import snapshot_download +snapshot_download('FunAudioLLM/Fun-CosyVoice3-0.5B-2512', local_dir='pretrained_models/Fun-CosyVoice3-0.5B') +``` + +Add `config.json` in `pretrained_models/Fun-CosyVoice3-0.5B/`: +```json +{ + "model_type": "cosyvoice3", + "architectures": [ + "CosyVoice3Model" + ] +} +``` + +> **Why `config.json` is required:** +> `AutoConfig.register("cosyvoice3", CosyVoice3Config)` only registers a class mapping. +> The loader still needs `model_type: "cosyvoice3"` from `config.json` to select that class. +> If no `config.json` is present, model type cannot be inferred automatically. +> If your downloaded checkpoint already includes a valid `config.json` with +> `model_type: "cosyvoice3"`, this manual step can be skipped. + +Run the offline verification script: +``` +python examples/offline_inference/cosyvoice3/verify_e2e_cosyvoice.py \ + --model pretrained_models/Fun-CosyVoice3-0.5B \ + --tokenizer pretrained_models/Fun-CosyVoice3-0.5B/CosyVoice-BlankEN +``` + +## Implementation Overview + +CosyVoice3 runs as a **2-stage Omni pipeline**. + +- **Stage 0** (`talker`) converts text + prompt audio to speech tokens. +- **Stage 1** (`code2wav`) converts speech tokens + prompt features to waveform via flow matching and HiFiGAN. + +Key components live in `vllm_omni/model_executor/models/cosyvoice3/cosyvoice3.py`. + +- `CosyVoice3MultiModalProcessor` builds the multimodal inputs: + - Tokenizes `prompt` and `prompt_text`. + - Extracts speech tokens and mel features from the prompt audio. + - Extracts a speaker embedding. +- `CosyVoice3Model` implements both stages: + - Stage 0 uses `CosyVoice3LM` and outputs speech tokens + conditioning features. + - Stage 1 runs the flow model (DiT-based CFM) and HiFiGAN to synthesize waveform. + +Stage wiring is configured in `vllm_omni/model_executor/stage_configs/cosyvoice3.yaml`. + +- Stage 0 emits latent speech tokens. +- Stage 1 consumes them via `custom_process_input_func` and outputs audio. + +## Example materials + +??? abstract "verify_e2e_cosyvoice.py" + ``````py + --8<-- "examples/offline_inference/cosyvoice3/verify_e2e_cosyvoice.py" + `````` diff --git a/examples/offline_inference/cosyvoice3/README.md b/examples/offline_inference/cosyvoice3/README.md new file mode 100644 index 000000000..895d3f660 --- /dev/null +++ b/examples/offline_inference/cosyvoice3/README.md @@ -0,0 +1,64 @@ +# CosyVoice3 + +## Setup + +Install dependencies: +``` +uv pip install -e . +``` + +> **Note:** This includes required libraries such as `librosa`, `soundfile`, +> `onnxruntime`, `x-transformers`, and `einops` via +> `requirements/common.txt` and platform-specific requirements files. + +Download the model snapshot: +``` +from huggingface_hub import snapshot_download +snapshot_download('FunAudioLLM/Fun-CosyVoice3-0.5B-2512', local_dir='pretrained_models/Fun-CosyVoice3-0.5B') +``` + +Add `config.json` in `pretrained_models/Fun-CosyVoice3-0.5B/`: +```json +{ + "model_type": "cosyvoice3", + "architectures": [ + "CosyVoice3Model" + ] +} +``` + +> **Why `config.json` is required:** +> `AutoConfig.register("cosyvoice3", CosyVoice3Config)` only registers a class mapping. +> The loader still needs `model_type: "cosyvoice3"` from `config.json` to select that class. +> If no `config.json` is present, model type cannot be inferred automatically. +> If your downloaded checkpoint already includes a valid `config.json` with +> `model_type: "cosyvoice3"`, this manual step can be skipped. + +Run the offline verification script: +``` +python examples/offline_inference/cosyvoice3/verify_e2e_cosyvoice.py \ + --model pretrained_models/Fun-CosyVoice3-0.5B \ + --tokenizer pretrained_models/Fun-CosyVoice3-0.5B/CosyVoice-BlankEN +``` + +## Implementation Overview + +CosyVoice3 runs as a **2-stage Omni pipeline**. + +- **Stage 0** (`talker`) converts text + prompt audio to speech tokens. +- **Stage 1** (`code2wav`) converts speech tokens + prompt features to waveform via flow matching and HiFiGAN. + +Key components live in `vllm_omni/model_executor/models/cosyvoice3/cosyvoice3.py`. + +- `CosyVoice3MultiModalProcessor` builds the multimodal inputs: + - Tokenizes `prompt` and `prompt_text`. + - Extracts speech tokens and mel features from the prompt audio. + - Extracts a speaker embedding. +- `CosyVoice3Model` implements both stages: + - Stage 0 uses `CosyVoice3LM` and outputs speech tokens + conditioning features. + - Stage 1 runs the flow model (DiT-based CFM) and HiFiGAN to synthesize waveform. + +Stage wiring is configured in `vllm_omni/model_executor/stage_configs/cosyvoice3.yaml`. + +- Stage 0 emits latent speech tokens. +- Stage 1 consumes them via `custom_process_input_func` and outputs audio. diff --git a/examples/offline_inference/cosyvoice3/verify_e2e_cosyvoice.py b/examples/offline_inference/cosyvoice3/verify_e2e_cosyvoice.py new file mode 100644 index 000000000..c962aac80 --- /dev/null +++ b/examples/offline_inference/cosyvoice3/verify_e2e_cosyvoice.py @@ -0,0 +1,197 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +import argparse +import os +from pathlib import Path + +import librosa +import numpy as np +import soundfile as sf +from vllm import SamplingParams +from vllm.assets.audio import AudioAsset + +from vllm_omni.entrypoints.omni import Omni +from vllm_omni.model_executor.models.cosyvoice3.config import CosyVoice3Config +from vllm_omni.model_executor.models.cosyvoice3.tokenizer import get_qwen_tokenizer +from vllm_omni.model_executor.models.cosyvoice3.utils import extract_text_token + + +def _ensure_mel_filters_asset() -> None: + repo_root = Path(__file__).resolve().parents[3] + filters_path = repo_root / "vllm_omni" / "model_executor" / "models" / "cosyvoice3" / "assets" / "mel_filters.npz" + if filters_path.exists(): + return + + source_url = "https://raw.githubusercontent.com/openai/whisper/main/whisper/assets/mel_filters.npz" + raise FileNotFoundError( + "Missing CosyVoice3 mel filter asset:\n" + f" {filters_path}\n" + "Download it with:\n" + f" mkdir -p {filters_path.parent} && " + f"curl -L {source_url} -o {filters_path}" + ) + + +def run_e2e(): + parser = argparse.ArgumentParser() + # ""FunAudioLLM/Fun-CosyVoice3-0.5B-2512 + parser.add_argument( + "--model", + type=str, + required=True, + help="Path to CosyVoice3 model directory (e.g., pretrained_models/Fun-CosyVoice3-0.5B/).", + ) + parser.add_argument("--stage-config", type=str, default="vllm_omni/model_executor/stage_configs/cosyvoice3.yaml") + parser.add_argument("--prompt", type=str, default="Hello, this is a test of the CosyVoice system capability.") + parser.add_argument( + "--prompt-text", + type=str, + default="You are a helpful assistant.<|endofprompt|>Testing my voices. Why should I not?", + ) + parser.add_argument("--audio-path", type=str, default="prompt.wav") + parser.add_argument( + "--tokenizer", + type=str, + required=True, + help="Path to tokenizer directory (e.g., /CosyVoice-BlankEN).", + ) + args = parser.parse_args() + _ensure_mel_filters_asset() + # Ensure tokenizer directory exists + if not os.path.exists(args.tokenizer): + raise FileNotFoundError(f"{args.tokenizer} does not exist!") + + # Ensure stage config exists + if not os.path.exists(args.stage_config): + raise FileNotFoundError(f"{args.stage_config} does not exist!") + + print(f"Initializing cosyvoice E2E with model={args.model}") + + # Initialize Omni + # This spins up the engine(s) based on the stage config + # We pass trust_remote_code=True same as Qwen examples + omni = Omni( + model=args.model, + stage_configs_path=args.stage_config, + trust_remote_code=True, + tokenizer=args.tokenizer, + log_stats=True, + ) + + sampling_cfg = {"top_p": 0.8, "top_k": 25, "eos_token_id": 6561 + 1} + + print("Model initialized. Preparing inputs...") + if args.audio_path: + if not os.path.exists(args.audio_path): + raise FileNotFoundError(f"Audio file not found: {args.audio_path}") + # Load at native sample rate + audio_signal, sr = librosa.load(args.audio_path, sr=None) + + # Validate sample rate before processing (similar to original CosyVoice) + min_sr = 16000 + if sr < min_sr: + raise ValueError( + f"Audio sample rate {sr} Hz is too low. " + f"Minimum required: {min_sr} Hz. " + f"Please provide audio with sample rate >= {min_sr} Hz." + ) + + audio_data = (audio_signal.astype(np.float32), sr) + else: + audio_data = AudioAsset("mary_had_lamb").audio_and_sample_rate + + prompts = { + "prompt": args.prompt, + "multi_modal_data": { + "audio": audio_data, + }, + "mm_processor_kwargs": { + "prompt_text": args.prompt_text, + "sample_rate": audio_data[1], + }, + } + + print(f"Generating for prompt: {args.prompt}") + + config = CosyVoice3Config() + tokenizer = get_qwen_tokenizer( + token_path=args.tokenizer, + skip_special_tokens=config.skip_special_tokens, + version=config.version, + ) + _, text_token_len = extract_text_token(args.prompt, tokenizer, config.allowed_special) + base_len = int(text_token_len) + min_len = int(base_len * config.min_token_text_ratio) + max_len = int(base_len * config.max_token_text_ratio) + + # Build SamplingParams for each stage (GPT, S2Mel, Vocoder) + gpt_sampling = SamplingParams( + temperature=1.0, + top_p=sampling_cfg["top_p"], + top_k=sampling_cfg["top_k"], + repetition_penalty=2.0, + min_tokens=min_len, + max_tokens=max_len, + stop_token_ids=[sampling_cfg["eos_token_id"]], + # allowed_token_ids=list(range(6561+3)), + detokenize=False, + ) + # Not used + s2mel_sampling = SamplingParams( + temperature=1.0, + top_p=1.0, + top_k=-1, + repetition_penalty=2.0, + max_tokens=256, + detokenize=False, + ) + + sampling_params_list = [gpt_sampling, s2mel_sampling] + + # Start profiling (requires VLLM_TORCH_PROFILER_DIR env var) + if os.environ.get("VLLM_TORCH_PROFILER_DIR"): + print("Starting profiler...") + omni.start_profile() + + # Generate (Omni orchestrator requires a per-stage SamplingParams list) + outputs = list(omni.generate(prompts, sampling_params_list=sampling_params_list[:2])) + + # Stop profiling and get results + if os.environ.get("VLLM_TORCH_PROFILER_DIR"): + print("Stopping profiler...") + profile_results = omni.stop_profile() + print(f"Profile traces saved to: {profile_results}") + + print(outputs) + # Verify outputs + print(f"Received {len(outputs)} outputs.") + for i, output in enumerate(outputs): + try: + ro_list = output.request_output or [] + if not ro_list: + print("No request_output found.") + continue + + for ro in ro_list: + # Multimodal output may be attached to RequestOutput or CompletionOutput. + mm = getattr(ro, "multimodal_output", None) + if not mm and ro.outputs: + mm = getattr(ro.outputs[0], "multimodal_output", None) + + if mm: + print(f"Multimodal output keys: {mm.keys()}") + if "audio" in mm: + audio_out = mm["audio"] + print(f"Generated Audio Shape: {audio_out.shape}") + out_path = f"output_{i}.wav" + sf.write(out_path, audio_out.cpu().numpy().squeeze(), 22050) + print(f"Saved audio to {out_path}") + else: + print("No multimodal output found.") + except Exception as e: + print(f"Error inspecting output: {e}") + omni.close() + + +if __name__ == "__main__": + run_e2e() From c8cf8a799db951f5c31af7a218e91575ca0a3ee9 Mon Sep 17 00:00:00 2001 From: Alex Brooks Date: Thu, 12 Mar 2026 21:13:34 -0600 Subject: [PATCH 38/38] [Refactor] Use SP Plan for LongCat Sequence Parallelism (#1772) Signed-off-by: Alex Brooks Co-authored-by: Didan Deng <33117903+wtomin@users.noreply.github.com> --- docs/design/feature/sequence_parallel.md | 40 +++- .../longcat_image_transformer.py | 214 ++++++++++-------- 2 files changed, 157 insertions(+), 97 deletions(-) diff --git a/docs/design/feature/sequence_parallel.md b/docs/design/feature/sequence_parallel.md index 18477e61a..3b899ffe3 100644 --- a/docs/design/feature/sequence_parallel.md +++ b/docs/design/feature/sequence_parallel.md @@ -55,6 +55,8 @@ The `_sp_plan` mechanism allows SP **without modifying `forward()` logic**. The - Tensor operations happen at `nn.Module` boundaries - Predictable sharding/gathering patterns +This is the ideal approach for integrating sequence parallelism into new models, as it is easier to maintain and ensure compatibility with other types of acceleration. + **How it works:** 1. Declare `_sp_plan` dict in your transformer class 2. Framework automatically applies hooks when `sequence_parallel_size > 1` @@ -201,6 +203,36 @@ class TransformerWithRoPE(nn.Module): } ``` +**Pattern 3: Shard RoPE for Dual Stream Attention** +In some cases, different streams in attention may need to handle sequence parallelism differently. For example, we may want to shard the image embeddings, while replicating the text embeddings to correctly configure joint attention. + +```python +class DualStreamTransformer(nn.Module): + """ + Dual-stream model where we need to replicate the text components, but shard + the image components to correctly handle sequence parallelism. + """ + _sp_plan = { + # In this case, the rope_preparer returns a tuple of len 4, where the + # first 2 items correspond to the text, and the second 2 correspond to + # visual inputs, so we only shard the second. + "rope_preparer": { + # Outputs 0, 1 (text) - NOT sharded (replicated) + # Outputs 2, 3 (image) - sharded + 2: SequenceParallelInput(split_dim=0, expected_dims=2, split_output=True), # img_cos + 3: SequenceParallelInput(split_dim=0, expected_dims=2, split_output=True), # img_sin + }, + # Shard transformer block INPUT + "transformer_blocks.0": { + "hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3), + }, + # Gather at output + "proj_out": SequenceParallelOutput(gather_dim=1, expected_dims=3), + } +``` + +NOTE: be careful to test adequately when refactoring classes that take this style of plan, as changing the order of the return values will break sequence parallelism. + ### API Reference **SequenceParallelInput Parameters:** @@ -240,7 +272,7 @@ class TransformerWithRoPE(nn.Module): ## Approach 2: Intrusive Modification (For Complex Cases) -For models with dynamic sharding logic that cannot be expressed via `_sp_plan`, manually insert shard/gather calls. Importantly, when taking this approach, be careful to ensure that you correctly manage the `_sp_shard_depth`; if the sequence parallel shard depth is 0, Ulysses will not be used. +For models with dynamic sharding logic that cannot be expressed via `_sp_plan`, manually insert shard/gather calls. **When to use:** @@ -253,18 +285,15 @@ from vllm_omni.diffusion.distributed.sp_sharding import sp_shard, sp_gather def forward(self, hidden_states, ...): if self.parallel_config.sequence_parallel_size > 1: - # hidden_states = sp_shard(hidden_states, dim=1) # ... computation ... if self.parallel_config.sequence_parallel_size > 1: output = sp_gather(output, dim=1) - # return output ``` -Note that currently, `sp_shard` / `sp_gather` do *not* automatically manage the `_sp_shard_depth`; you need to be careful to manage it yourself. --- @@ -439,6 +468,7 @@ Complete examples in the codebase: | Model | Path | Pattern | Notes | |-------|------|---------|-------| +| **LongCat** | `vllm_omni/diffusion/models/longcat_image/longcat_image_transformer.py` | Dual-stream | Text components replicated, image components sharded | | **Qwen-Image** | `vllm_omni/diffusion/models/qwen_image/qwen_image_transformer.py` | Dual-stream + preprocessing | auto_pad, separate RoPE | | **Wan2.2** | `vllm_omni/diffusion/models/wan2_2/wan2_2_transformer.py` | Dual-Transformer + RoPE | Video transformer | | **Z-Image** | `vllm_omni/diffusion/models/z_image/z_image_transformer.py` | Unified sequence | Concatenated input | @@ -453,7 +483,7 @@ Complete examples in the codebase: Adding Sequence Parallel support to a transformer: 1. ✅ **Choose approach** - Use `_sp_plan` for standard cases, intrusive modification for complex cases -2. ✅ **Identify sharding boundaries** - Where should tensors be split/gathered? +2. ✅ **Identify sharding boundaries** - Where should tensors be split/gathered? And which module boundaries need to be moved to facilitate this? 3. ✅ **Extract inline operations** - Move `torch.cat`, `pad_sequence`, etc. to submodules 4. ✅ **Define `_sp_plan`** - Declare shard/gather points as class attribute 5. ✅ **Use `auto_pad` for variable lengths** - Support non-uniform sequences diff --git a/vllm_omni/diffusion/models/longcat_image/longcat_image_transformer.py b/vllm_omni/diffusion/models/longcat_image/longcat_image_transformer.py index ccf35ea4e..8d8e523d6 100644 --- a/vllm_omni/diffusion/models/longcat_image/longcat_image_transformer.py +++ b/vllm_omni/diffusion/models/longcat_image/longcat_image_transformer.py @@ -18,11 +18,10 @@ from vllm.model_executor.model_loader.weight_utils import default_weight_loader from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata from vllm_omni.diffusion.attention.layer import Attention -from vllm_omni.diffusion.data import OmniDiffusionConfig -from vllm_omni.diffusion.distributed.parallel_state import ( - get_sequence_parallel_rank, - get_sequence_parallel_world_size, - get_sp_group, +from vllm_omni.diffusion.data import DiffusionParallelConfig, OmniDiffusionConfig +from vllm_omni.diffusion.distributed.sp_plan import ( + SequenceParallelInput, + SequenceParallelOutput, ) from vllm_omni.diffusion.forward_context import get_forward_context from vllm_omni.platforms import current_omni_platform @@ -50,6 +49,7 @@ class FeedForward(nn.Module): class LongCatImageAttention(nn.Module): def __init__( self, + parallel_config: DiffusionParallelConfig, query_dim: int, heads: int = 8, dim_head: int = 64, @@ -64,7 +64,7 @@ class LongCatImageAttention(nn.Module): pre_only: bool = False, ): super().__init__() - + self.parallel_config = parallel_config self.head_dim = dim_head self.inner_dim = out_dim if out_dim is not None else dim_head * heads self.query_dim = query_dim @@ -209,12 +209,8 @@ class LongCatImageAttention(nn.Module): encoder_query = self.norm_added_q(encoder_query) encoder_key = self.norm_added_k(encoder_key) - # Check if SP is enabled from forward context (set by LongCatImageTransformer2DModel) - forward_ctx = get_forward_context() - sp_size = forward_ctx.sequence_parallel_size - use_sp_joint_attention = sp_size > 1 and not forward_ctx.split_text_embed_in_sp - - if use_sp_joint_attention: + sp_size = self.parallel_config.sequence_parallel_size + if sp_size is not None and sp_size > 1: # SP Mode: Use common helper for RoPE + joint attention hidden_states = self._sp_attention_with_rope( img_query=query, @@ -248,12 +244,22 @@ class LongCatImageAttention(nn.Module): # In SP mode, image part is chunked: (B, txt_len + img_len/SP, D) # Check if SP is enabled and we have text_seq_len info - forward_ctx = get_forward_context() - sp_size = forward_ctx.sequence_parallel_size + sp_size = self.parallel_config.sequence_parallel_size text_seq_len = kwargs.get("text_seq_len", None) - use_sp_single_stream = sp_size > 1 and not forward_ctx.split_text_embed_in_sp and text_seq_len is not None + forward_ctx = get_forward_context() + + if ( + sp_size is not None + and sp_size > 1 + and not forward_ctx.split_text_embed_in_sp + and text_seq_len is not None + ): + # Ensure that the SP split won't cause out of bounds issues. + if text_seq_len < 0 or text_seq_len > query.shape[1]: + raise ValueError( + f"text_seq_len={text_seq_len} is out of bounds for sequence length {query.shape[1]}" + ) - if use_sp_single_stream: # SP Mode for single-stream block: # Split QKV into text and image parts, then use common helper hidden_states = self._sp_attention_with_rope( @@ -301,6 +307,7 @@ class LongCatImageAttention(nn.Module): class LongCatImageTransformerBlock(nn.Module): def __init__( self, + parallel_config: DiffusionParallelConfig, dim: int, num_attention_heads: int, attention_head_dim: int, @@ -309,10 +316,12 @@ class LongCatImageTransformerBlock(nn.Module): ): super().__init__() + self.parallel_config = parallel_config self.norm1 = AdaLayerNormZero(dim) self.norm1_context = AdaLayerNormZero(dim) self.attn = LongCatImageAttention( + parallel_config=parallel_config, query_dim=dim, added_kv_proj_dim=dim, dim_head=attention_head_dim, @@ -429,6 +438,61 @@ class LongCatImageTimestepEmbeddings(nn.Module): return timesteps_emb +class RoPEPreparer(nn.Module): + """ + This module encapsulates RoPE computation to enable _sp_plan sharding + for text / image components. + + For LongCat, which uses dual-stream attention, this means that text + components are replicated across SP ranks, while image components are + sharded. + """ + + def __init__(self, pos_embed: LongCatImagePosEmbed): + super().__init__() + self.pos_embed = pos_embed + + def forward( + self, + txt_ids: torch.Tensor, + img_ids: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + """ + Compute RoPE embeddings for text and image sequences. + + Args: + txt_ids: Text position IDs (txt_seq_len, n_axes) + img_ids: Image position IDs (img_seq_len, n_axes) + + Returns: + Tuple of cosine / sine components for text & image + in the order: (txt_cos, txt_sin, img_cos, img_sin) + + NOTE: careful about output orders if this is refactored in the + future; we need to match the _sp_plan indices, since text + components (0 & 1) need to be replicated across SP ranks, + while image components (2 & 3) must be sharded. + """ + # Concatenate and compute RoPE for full sequence + ids = torch.cat((txt_ids, img_ids), dim=0) + + if current_omni_platform.is_npu(): + freqs_cos, freqs_sin = self.pos_embed(ids.cpu()) + freqs_cos = freqs_cos.npu() + freqs_sin = freqs_sin.npu() + else: + freqs_cos, freqs_sin = self.pos_embed(ids) + + # Split into text and image portions + txt_len = txt_ids.shape[0] + txt_cos = freqs_cos[:txt_len] + txt_sin = freqs_sin[:txt_len] + img_cos = freqs_cos[txt_len:] + img_sin = freqs_sin[txt_len:] + + return txt_cos, txt_sin, img_cos, img_sin + + class LongCatImageSingleTransformerBlock(nn.Module): """ Single-stream Transformer block for LongCat with SP (Sequence Parallelism) support. @@ -437,7 +501,14 @@ class LongCatImageSingleTransformerBlock(nn.Module): This keeps the block logic clean and centralizes SP logic in the attention layer. """ - def __init__(self, dim: int, num_attention_heads: int, attention_head_dim: int, mlp_ratio: float = 4.0): + def __init__( + self, + parallel_config: DiffusionParallelConfig, + dim: int, + num_attention_heads: int, + attention_head_dim: int, + mlp_ratio: float = 4.0, + ): super().__init__() self.mlp_hidden_dim = int(dim * mlp_ratio) @@ -448,6 +519,7 @@ class LongCatImageSingleTransformerBlock(nn.Module): # SP handling is delegated to LongCatImageAttention via text_seq_len kwarg self.attn = LongCatImageAttention( + parallel_config=parallel_config, query_dim=dim, dim_head=attention_head_dim, heads=num_attention_heads, @@ -511,6 +583,23 @@ class LongCatImageTransformer2DModel(nn.Module): _repeated_blocks = ["LongCatImageTransformerBlock", "LongCatImageSingleTransformerBlock"] + # Sequence Parallelism for LongCat (following diffusers' _cp_plan pattern) + _sp_plan = { + "": { + # Chunk the hidden states prior to the forward() + "hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3), + }, + # Shard RoPE image embeddings after rope_preparer computes them + # Outputs 0, 1 are text components, so they aren't sharded + # Outputs 2, 3 are image components and are sharded + "rope_preparer": { + 2: SequenceParallelInput(split_dim=0, expected_dims=2, split_output=True), + 3: SequenceParallelInput(split_dim=0, expected_dims=2, split_output=True), + }, + # Gather at the last linear projection + "proj_out": SequenceParallelOutput(gather_dim=1, expected_dims=3), + } + def __init__( self, od_config: OmniDiffusionConfig, @@ -534,6 +623,7 @@ class LongCatImageTransformer2DModel(nn.Module): self.parallel_config = od_config.parallel_config self.pos_embed = LongCatImagePosEmbed(theta=10000, axes_dim=axes_dims_rope) + self.rope_preparer = RoPEPreparer(self.pos_embed) self.time_embed = LongCatImageTimestepEmbeddings(embedding_dim=self.inner_dim) @@ -543,6 +633,7 @@ class LongCatImageTransformer2DModel(nn.Module): self.transformer_blocks = nn.ModuleList( [ LongCatImageTransformerBlock( + parallel_config=self.parallel_config, dim=self.inner_dim, num_attention_heads=num_attention_heads, attention_head_dim=attention_head_dim, @@ -554,6 +645,7 @@ class LongCatImageTransformer2DModel(nn.Module): self.single_transformer_blocks = nn.ModuleList( [ LongCatImageSingleTransformerBlock( + parallel_config=self.parallel_config, dim=self.inner_dim, num_attention_heads=num_attention_heads, attention_head_dim=attention_head_dim, @@ -581,40 +673,11 @@ class LongCatImageTransformer2DModel(nn.Module): return_dict: bool = True, ) -> torch.FloatTensor | Transformer2DModelOutput: fwd_context = get_forward_context() - # Before: hidden_states shape = (B, img_seq_len, in_channels) - # After: hidden_states shape = (B, img_seq_len // SP, in_channels) sp_size = self.parallel_config.sequence_parallel_size - # Store SP size in forward context for sub-modules to access if sp_size is not None and sp_size > 1: - sp_world_size = get_sequence_parallel_world_size() - sp_rank = get_sequence_parallel_rank() - original_shape = hidden_states.shape - hidden_states = torch.chunk(hidden_states, sp_world_size, dim=1)[sp_rank] - # LongCat uses dual-stream (text + image) with joint attention - # Text embeddings should be replicated across SP ranks for correctness - fwd_context.sequence_parallel_size = sp_size fwd_context.split_text_embed_in_sp = False - # Mark SP as active so attention layers; we need this to ensure we use - # Ulysses instead of NoParallelAttention since we don't set an sp plan - # for this model. - # TODO: would be nice to refactor this to use sp_plan if possible to - # tracking this directly, even though we only have one level. - fwd_context._sp_shard_depth = 1 - - # Debug log (only first forward) - if not hasattr(self, "_sp_forward_logged"): - self._sp_forward_logged = True - logger.info( - f"[LongCat Transformer] SP enabled: sp_size={sp_size}, world_size={sp_world_size}, " - f"rank={sp_rank}, original_shape={original_shape}, chunked_shape={hidden_states.shape}" - ) - else: - fwd_context._sp_shard_depth = 0 - if not hasattr(self, "_sp_forward_logged"): - self._sp_forward_logged = True - logger.info(f"[LongCat Transformer] SP disabled: sp_size={sp_size}") - + # Hidden states are sharded prior to forward() when sp is active hidden_states = self.x_embedder(hidden_states) timestep = timestep.to(hidden_states.dtype) * 1000 @@ -622,46 +685,17 @@ class LongCatImageTransformer2DModel(nn.Module): temb = self.time_embed(timestep, hidden_states.dtype) encoder_hidden_states = self.context_embedder(encoder_hidden_states) - ids = torch.cat((txt_ids, img_ids), dim=0) + # Compute RoPE embeddings via rope_preparer module + # _sp_plan will automatically shard img_cos/img_sin (outputs 2, 3) + # txt_cos/txt_sin (outputs 0, 1) remain replicated for dual-stream attention + txt_cos, txt_sin, img_cos, img_sin = self.rope_preparer(txt_ids, img_ids) - if current_omni_platform.is_npu(): - freqs_cos, freqs_sin = self.pos_embed(ids.cpu()) - image_rotary_emb = (freqs_cos.npu(), freqs_sin.npu()) - else: - image_rotary_emb = self.pos_embed(ids) - - # SP: Chunk RoPE embeddings along sequence dimension - if sp_size is not None and sp_size > 1: - sp_world_size = get_sequence_parallel_world_size() - sp_rank = get_sequence_parallel_rank() - freqs_cos, freqs_sin = image_rotary_emb - txt_len = txt_ids.shape[0] - - # Split RoPE into text and image portions - # txt_freqs: (txt_seq_len, head_dim) - keep full for all ranks - # img_freqs: (img_seq_len, head_dim) -> (img_seq_len // SP, head_dim) - txt_freqs_cos = freqs_cos[:txt_len] - txt_freqs_sin = freqs_sin[:txt_len] - img_freqs_cos = freqs_cos[txt_len:] - img_freqs_sin = freqs_sin[txt_len:] - - # Chunk image RoPE for each SP rank - # img_freqs_cos: (img_seq_len // SP, head_dim) - # img_freqs_sin: (img_seq_len // SP, head_dim) - img_freqs_cos = torch.chunk(img_freqs_cos, sp_world_size, dim=0)[sp_rank] - img_freqs_sin = torch.chunk(img_freqs_sin, sp_world_size, dim=0)[sp_rank] - - # Optionally chunk text RoPE if split_text_embed_in_sp is True - if get_forward_context().split_text_embed_in_sp: - txt_freqs_cos = torch.chunk(txt_freqs_cos, sp_world_size, dim=0)[sp_rank] - txt_freqs_sin = torch.chunk(txt_freqs_sin, sp_world_size, dim=0)[sp_rank] - - # Reconstruct image_rotary_emb with chunked values - # Final shape: (txt_seq_len + img_seq_len // SP, head_dim) - image_rotary_emb = ( - torch.cat([txt_freqs_cos, img_freqs_cos], dim=0), - torch.cat([txt_freqs_sin, img_freqs_sin], dim=0), - ) + # Reconstruct image_rotary_emb with chunked values + # Final shape: (txt_seq_len + img_seq_len // SP, head_dim) + image_rotary_emb = ( + torch.cat([txt_cos, img_cos], dim=0), + torch.cat([txt_sin, img_sin], dim=0), + ) for block in self.transformer_blocks: encoder_hidden_states, hidden_states = block( @@ -680,13 +714,9 @@ class LongCatImageTransformer2DModel(nn.Module): ) hidden_states = self.norm_out(hidden_states, temb) - output = self.proj_out(hidden_states) - # SP: All-gather output to reconstruct full sequence - if sp_size is not None and sp_size > 1: - output = get_sp_group().all_gather(output, dim=1) - # Mark SP as inactive after gathering - get_forward_context()._sp_shard_depth = 0 + # proj_out gathers for sequence parallel + output = self.proj_out(hidden_states) if not return_dict: return (output,)