From 7b9d307a581b6f62ebe2b064d57f00602b30ffa6 Mon Sep 17 00:00:00 2001 From: Jiannan Wang Date: Sat, 10 Aug 2024 05:20:35 +0800 Subject: [PATCH] add emulator --- docs/pictures/emulator.png | Bin 0 -> 71590 bytes docs/pictures/emulator_distributed.png | Bin 0 -> 45824 bytes docs/pictures/emulator_dtensor.png | Bin 0 -> 49966 bytes docs/pictures/emulator_mesh_collectives.png | Bin 0 -> 45996 bytes .../pictures/training_losses_diff_in_bf16.png | Bin 0 -> 102040 bytes test/emulator/__init__.py | 0 test/emulator/common_emulator.py | 35 + test/emulator/test_distributed.py | 212 +++++ test/emulator/test_dtensor.py | 118 +++ test/emulator/test_mesh_collectives.py | 228 +++++ test/emulator/test_topo.py | 85 ++ vescale/emulator/README.md | 220 +++++ vescale/emulator/__init__.py | 18 + vescale/emulator/all_gather.py | 103 +++ vescale/emulator/all_reduce.py | 347 ++++++++ vescale/emulator/all_to_all.py | 78 ++ vescale/emulator/calculate_chunk_size.py | 154 ++++ vescale/emulator/comm_api.py | 342 ++++++++ vescale/emulator/comm_primitive.py | 359 ++++++++ vescale/emulator/device_mesh.py | 686 +++++++++++++++ vescale/emulator/distributed.py | 809 ++++++++++++++++++ vescale/emulator/emulator_instrumentation.py | 110 +++ vescale/emulator/mesh_collectives.py | 211 +++++ vescale/emulator/nccl/__init__.py | 0 vescale/emulator/nccl/constants.py | 159 ++++ vescale/emulator/nccl/graph/__init__.py | 0 vescale/emulator/nccl/graph/tuning.py | 388 +++++++++ vescale/emulator/nccl/include/__init__.py | 0 vescale/emulator/nccl/include/comm.py | 40 + vescale/emulator/nccl/include/graph.py | 69 ++ vescale/emulator/nccl/include/info.py | 58 ++ vescale/emulator/nccl/init.py | 86 ++ vescale/emulator/nccl/nccl_profiler_result.py | 74 ++ vescale/emulator/primitives.py | 224 +++++ vescale/emulator/reduce_kernel.py | 55 ++ vescale/emulator/reduce_scatter.py | 137 +++ vescale/emulator/topo.py | 214 +++++ vescale/emulator/utils.py | 79 ++ 38 files changed, 5698 insertions(+) create mode 100644 docs/pictures/emulator.png create mode 100644 docs/pictures/emulator_distributed.png create mode 100644 docs/pictures/emulator_dtensor.png create mode 100644 docs/pictures/emulator_mesh_collectives.png create mode 100644 docs/pictures/training_losses_diff_in_bf16.png create mode 100644 test/emulator/__init__.py create mode 100644 test/emulator/common_emulator.py create mode 100644 test/emulator/test_distributed.py create mode 100644 test/emulator/test_dtensor.py create mode 100644 test/emulator/test_mesh_collectives.py create mode 100644 test/emulator/test_topo.py create mode 100644 vescale/emulator/README.md create mode 100644 vescale/emulator/__init__.py create mode 100644 vescale/emulator/all_gather.py create mode 100644 vescale/emulator/all_reduce.py create mode 100644 vescale/emulator/all_to_all.py create mode 100644 vescale/emulator/calculate_chunk_size.py create mode 100644 vescale/emulator/comm_api.py create mode 100644 vescale/emulator/comm_primitive.py create mode 100644 vescale/emulator/device_mesh.py create mode 100644 vescale/emulator/distributed.py create mode 100644 vescale/emulator/emulator_instrumentation.py create mode 100644 vescale/emulator/mesh_collectives.py create mode 100644 vescale/emulator/nccl/__init__.py create mode 100644 vescale/emulator/nccl/constants.py create mode 100644 vescale/emulator/nccl/graph/__init__.py create mode 100644 vescale/emulator/nccl/graph/tuning.py create mode 100644 vescale/emulator/nccl/include/__init__.py create mode 100644 vescale/emulator/nccl/include/comm.py create mode 100644 vescale/emulator/nccl/include/graph.py create mode 100644 vescale/emulator/nccl/include/info.py create mode 100644 vescale/emulator/nccl/init.py create mode 100644 vescale/emulator/nccl/nccl_profiler_result.py create mode 100644 vescale/emulator/primitives.py create mode 100644 vescale/emulator/reduce_kernel.py create mode 100644 vescale/emulator/reduce_scatter.py create mode 100644 vescale/emulator/topo.py create mode 100644 vescale/emulator/utils.py diff --git a/docs/pictures/emulator.png b/docs/pictures/emulator.png new file mode 100644 index 0000000000000000000000000000000000000000..5193fc96f6eb8315e08bfdef1acaaa57fb271d75 GIT binary patch literal 71590 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 vzAoAy7KAWz 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 literal 0 HcmV?d00001 diff --git a/docs/pictures/emulator_distributed.png b/docs/pictures/emulator_distributed.png new file mode 100644 index 0000000000000000000000000000000000000000..1402b338d82735422d14a7734d7d03189dc6fb95 GIT binary patch literal 45824 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�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�{wo+_VIUr>P8843 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

L-l?Zu3T5Rr3pedh1`;rX44XsuFLoOdbemrIi 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 literal 0 HcmV?d00001 diff --git a/docs/pictures/emulator_mesh_collectives.png b/docs/pictures/emulator_mesh_collectives.png new file mode 100644 index 0000000000000000000000000000000000000000..b16f8a6e0520e3ad3f824719250fa6329fc7a804 GIT binary patch literal 45996 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

hsVZdQ3Ol=v$YtKD1w{Bmgi?? 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

%DNr8dR+a>8Gd+BZyd@+sWa!e4hU>17acwZ< zp~Jd1d7@;`p(*eu>23G3a%QA2aTrnATyN@fu)8=Cn3F&=Heh5h(QYs8bBJG(NfiU* z28~P?ZwfCb9C^sAcreYmd_TtDGLvV0_oEMicU;8Ghn{k-jFvZ$M~i=DRBU zx|J{IYHsgv^ zlN9&%ZiATS@zUKfegnA~w=5P$MyuJ9DF9(wTUWQlXB2s+@dup{2!e$oP)o_qQec3{ zc#NhPA7or?RX?(5)jk$e@wr%gZ?_F$fAr9!veWu1G&r*jh>^t+e8{8LYv33QSD0IqxkeR{v3I1Y62 z3F)QK@7!mk&4>HIrtp@@xrC z4#v#{lZm*)V!ECK1{?{H-4Z4)O%Nx>!h69ZtM_DqcX5cXoD9M8Bxf`u%v9+|cGXN* z4?iRjhwv_xFc?$7&Gt$`kP*FKMAyUB>o&|Up1Qr$aEF9lAdTE}Btu6hDNKBnqOX{X zNQMU2xPXpSDvXj&em1lmiaxic2p@wEJyi4#{4W)c77r;8k2UYSH;MWjD+E1gNBFbd z7-i5YWH$`ia{isyO{jDE9Bt|#oU{(^Z)-&3Xe#G{pAu%J?w`9Jx{dt{mKXE6*3Ca$ zID@oI^V8EAS9Q^Nnzd)hB15Z8!lw|?Mjb<(EXdp1gV|WqRT5hsRO7GCb%06N%K;}Z z8yfxj<4>t&t$1Vry{(aC64K+xh7lyf7(L?Ch1Yq%Y`N3_OJ;0A9?0fj?+$t1pR%F# z>^Z;#Q~t!k!!v&K7a;vWv|0jRJagpGoHs>V-?h*6UsHq|4w-;pkdOwn3BDEKyC(WZ zLo-1aXeb}JlcxHvh9PA`gPj60-;5n&mO-Wp>;siRJdrE*_n1lBfl9g_NcBf9O@kiQs*H*PR9rqd6)Joi9v%I06TkD2c9+{{#1=hpb5xuS zSefWMXwFKzzRU8t+Am6nl7=d_&9{T8Cv?g4Puu|$AR;KirY5HGDLsK-#6uDr{E zH&atnGcpoh7$i0YEIPLG6TNk~fOIBQIc*q90*ZLXZ`RsHZl9Bza2raIQ5ajJUsA-? zy#?YFxPHh8dqOhf-8E@HrQ$yS)V#we>kV^|$ZirHNlDQ%CZtVpeMyqAQ|=irw$&9g zus|T+{39I!Lxm+R6oyamH{9Zo8j$K@R?O1);_z!;-lB3d>D}sOc%vgd$?(u9$|K5_ zaPl5idvOLS<2?jaD2U-!^wPm7Q3n2`W@Dc@ff+0#?o+8-wluThKWxF4f{9e(y-C3r3rg)~XuvXFM~s;7zB2-jXb^@oqf5v{_2f zb}PA!<~$J-cIsPNm_;jO+)4LvDDx*_cttm=Nhva~(9r}1UUa^9=(f5b0lv*`j>~_{ ze-Ut!2PPyX)sFd-B7M@a>t?8G`{KAi3TQosYgd84-p}`J82j#@$$II3JLw9#UFH;- zPyKY7D^tbC$In%y{Z};f#0Y@0RfuV!ztxHOh{vVK!c5^|2R>2Ha&KAT92=4aXtjJN zOZ+aXV}l4y433AvGks|lKb-p6Aidwy9Sd9ZoqBPK5JR_lh~L;>yk@`tr9+RT z#Grvvy88(Qx+-i1Vfsf5VE<*#;*II|3+~<|6>$n;??RTA!UJ1wko$43XlrTF4%Ldw z)iHk3AMeJEQWA2}QDo)hJns}iBV`P4p+qK%>%z_R3FH1MKQ*A2luVvyHO543g0=ma z{RmBt>$~Gy=fV=p8c8r0k}sO$z*^z+(v#6X{MNXh_H?@z?R`5D-Lo>rbb_J7X&@9L zaQC7@gGc!pOfQz-b;XfG0vAd~8p@icV7*I~0{E88cg?0T4uw&R+1ZE4J9g?B5V&ou z2ZW_62(d{-fBDraU0E-UDO~4aDqUJuRw3XHIE8$UJ^*09p3i5E>XkZ2S8f_txD4T3 zXt3Hj2?W7YZSFUKQ-NF-f9jsMCTCI}?fV~4L?3IvKWRysmWD=3Vq#$#{lpEPF&{cm z5uzGEAS5@U-Ddz2^527@IZi0yoTl9x>vtVe3yvyD!e&uJliM~)RD^acvl&)Pwn%lW z<*ZNMyH}*L5S|fL`F9E8fG>$5VkTKkb`yYYD0$H6&z$xUEh!@P`>;)}%MDgCBGyYY zuO1}HMTmCWPq`6dBc#nKBSjTL`<3EPsU5bGaFYrq|EMIG8q-k^KQ&iE>Il_>jn0$t zIS1q0j(eq5fIk@lSB3x_Z4n)o(lA`SLW#2j78KQaLjWK4_S%<68A)+o1eo&VGst6i zYayf8fMcUK$4Pr@$ZCy8_tW;;ani5mPp+-E<6*F9Y`Nk^48F2483sA5zWsioT|?i; z)EcxEzL(k@r{J&-3JEO;$2f`2HNKTbwO)-Q?+hs767ZZ$E3qb~Y{zNN$4RKrk_@^@ zXKU7vK-VSwHpGjgRCTKc%prg!nAdspfd+0`p{HhYHEnoBlEv-eR0RjGrdDF4U^)P_ zvjfb0L~<3s%xB7C|2KD_2oH-4&ezJ@m!;e&`C-p&;75t*Nz8szwVA9mGM}zF6S#gr z!q9G4!1=!R#p@3}E4TtGlKPh&PDg)>1gG}xmzy-M`7rkLDq%P%WBPm)5<@CWVRa9rWe&MW zHsg%|M9f;mi12W5)XhIHfL6h%W0gjI@<0{HT7wZd*&TogdA(aeWo{zHL#u^hxCal| z^H0j)(nmKv$Poyy`2q+Zj15rxU;3YxoKn%?Nsv*YfTK)DRm#xmT8usQ9HDS;dv=V zfRCB&M7AL0;e0LFkpFQr!0}+5n9V4V6bS%Wx$UQ#fkbLFtfmX3{xk22BIZkC(SJLw zoA~(gBVgb*b8@G4Im;8p7v~#4QIonDj84c^#w0tFkO@QR*o~TFzIoSusnk={c0b)& zqyXzzx$(#4+#mCI0|y8bHS0}X)h!M+7=yUYXKnxKjt~LHC$>(j!kO0CGVXFmIoL>* zo<7qzwE)5L8!&#)1Yim*mZ_Elc0|^|zrfuAR5&NpU@b6UnBMmQ6K#Jq2?XfF7(k-w z`Th*`6Z7eGVMLL+#GcB%!55B9N`uJq?Ix?mY9Iv%5f>ATtNuV>j1;Ug>SriI3bkX? zUS@vdm<8F(PH%Ys_tdG6xQ;y=mpe$pX#Dp*4GT-hUaUATZ`#9|kTeh`c3K0?t^8O2 zQ+<|X{$y=q`45cBTcYZ+GR9&CsqY*FAH-sJz=0r{8H==j|9yYb$5)@^k-n_{mN1Q0 zJ5mt9v5>&F`$9ez%mx=AtVXI^8e$AM7!q>H<4PP=$SGqObRcAopx=3nlf{6 zg%2>k7Pa5?_pPPD1WP9Hwhu~{)Fj*m6@MJav?5(QmOozI>W^6Fwwz>~Zn3_R;X}s> z67?b&K-Lf{N+==d@i385Qfn!u{SUMqOBwb-sz!}mEvYYoqqepd%2&#|TBU|@Bw@P1 zB4pw~<}0>Zpel5tH!iRksW#l{6mL!_{`S#X_W(X;77ES=vxETr#o+F~?~|)73eh1# zQyXm={^E2UFH=@tX&*YC>&_uibgAUx=6b$pNyUo4D7&M=ReGypT8LxEL0}>X5^>U9 z;P_=?r1j5lO*iQ2{{n_y zL$i#8OX;^d$`UWs14wEqCJDl6oyoiwTsfCsM%Mq2x*VnIpOb;dC41)RMhyIR@OIEIgWI zQPVgnj;^Zcl?3X6tPIdStyBo2#=G$_7AEE6l$IcpOYGHmiM%b~K1=~A18AZzP~Tgf zn!z8bh^`M?&ROUfJID~_ zZIO!!qZ?h8mZV(%){m|Y>(>PoD=If2(;3HFVgBE}Uu>aCcK8zLw2XhKk|ujAys5(T zOjC&Cz0#y4eXJ)1gBjPIX5Nn66&URWyQf=v!j`OV$4OI?doDj+th9 zNMyYPYl#mNzWhOI5*1@%xn#B53py2hc$3Xziz`!LBx3s4Ccy6`BaprFK5OE|Z3rQ-W-JO1@6xiv~7pe_P=p8pPd!&X%N0OgeVXU?TF~S3Q`nkiP_qI#oVrT0EM2Z#K=(o}#ZVR9*iiB!G8$ zr4BWPo(LijW0vmTsS$@(PGQ>#=)J&16`;}1qK^$Y`~Oe=C?m89Z^=P|9P-UqNL3CZ zRU*Ikr^oWQ!o=woj^BUD-2aBADi{pop4n9!nnD#&bEIr^k#-NF68&>rnWBHgx5@uI zh6O%rhpD#E>Q5VO(Y3*ce;sK5iBtafn=DA_Hlp7RsQ%sqrCtBc3;Kv3c)g;<8dax| z{uiYOuJFjL3!OyI9Rln{NFW!8V;d2wP+GE0rSM8_kM!^{W9QbeBTe3)v%tI z|9_i5T9V_(R$fY~Y8(9Ten5MCe_;Vt7nviGsj6a$BxHF!aF7MIry1fU88rH6ja4|Y z2B7y$axl4rIgvQmd`w-yM8NrS!oM0eIOJSb01wnUMza#S`}B{Y@D^lu>Q=MD*i%1m zWf$G}iN@Y~eGO*{ibs5USV1CzpnG#QwbD_MRb8T?m71=}deLu=oP&dhhX*VJ$45p+hK9DlQD5C^2l@n%xw_s?PJ8@wA$WrvLHJ+sNm zCfPfD%HCv;ez))6zgPd9^XeSu{jB@B?&}^G{WJ?{h!j?Zo$G<&bL;q$W{ zr{wk?`aoF}KSg07WOn_*p(NxHMH@U6o~>GIP|{5Lq`|QFN=y<5BRp@;vD`z-o?pG6Q0YaW%wxm1>+`wKZ@bXFj5%+`9b3 zOOD@7D!A-s?tEdGN$-0|`GJuH*E%^NKHfapf|bZcFx9tZVaf5cD*1=^edS^x6eAr$ zwB-1y(|kIRwccfHbuzMXlOKUbBPK?L(%`n)1>Bc*=x#a|LNdg-r*fdw12C4gnspyT zT>R{#K?}_GLm$Oyqbs~A*tSC07zsoLkI6%m?ZsHE%@dJ;;WPhBcK$a(a1nd*J6i0| zpFacy1oRHStSx;Ar=Ijz=30SYWH(pGIjbF(>A`w@^=i)_X8C_2Xn5UD#m=i^|8>RC zpI!NuO%`o>$;fm;GBQ{K9SsfF&o0^}8Jk{OT3Yt@_E|DWAbzN^+Kf2p=(2J>_F(18 zCL7KB6QIf2&TOsnD2U>6Yaw5$j~MaHUyD=!%#a9d_CB$&u-IszjOc_I2KZfyx(l$c z2|ZZO7GUi5b9MUw!|!1PLZ5X6R(4JfI%>d1S6jSY%MEMs6D1_%S3O%c*38*eq1FIWR=`d zT6I90wrZ1*Ad9R?XUmOKBdRZP@|C_@`A>(Bq0xoZ8_L?|eHQ2ob3rjb7l!B?l9Qj$X zP+78zYg99SXUNRH($h;$NRa6_ntc_Ig%N02mUuOAj8J&9r!ec5nScNIfn?0^LH38z zO)q{5C1vF@$04l_Zb!GO1%}0rR)Vl;MgpWv(U*jEr;ps9?AEMRP*c8uc{(1mos8tP zxHGm<<4=jnGvk~4@;i}=J9TePjZ8-Fl%EFbivz8Z60@W&Yn|`f^IK)^RC`ImrJGy7 zjhPXzXFkUU!m*LA!Z6>B$sayu+>D>%N4OL4RZcp=;FaB` z)HW?}JB%J)#;GxMTh98?=0~lrDFmn`$9;k8#g?@c+I^29KLlYtoQd8w zwR%VsL@(EH_(Lekjh(cFy^Qo}ON_*U|aO zZhB%OCW>!MI(l%Fn0^&@>#tOVT8r;nRf1S}HcFp8iL%Mr3e#>l*M9;(h#ZqV5RmtE z)TCqo6qhA;y|-PvaF2qYgOL%FhTGv+li466%G{fUw;+ZrgM}pFG#N@02{GoNSrez` zaQ-8kK9m?A&*QPT_~)>wl}j}0isbin)oaGVI*@@J6516GQkg6@cpgbo;jr*6I~tC+ z1XlgL{jshvNN8yazZL}Qg2xQh*Arj)oK_<@AWj}N zmTwCcN_a#BV84~(Vph;~1@5Ej`BcAF8x2R*bnh$BPHuGH23=5pM6#HlsQ1TcB1zc< z^W%r6!sdMh?uA2Q|9pZF-A?bQse=qXmV&dt@~i8W+EUo0&Q;rJJMZw*{7HCe55krp z^8M`nz4>|<##k+=81!vvfv^&+FPv#Su}koxxALb|=_fP)QNn2#$4UYoUF2kr3o2w;*s-43M9UxE*~}IahsQ0B{a272J7l7n7Tbv?R_%;W}2aO^>Dqx zP*3l?w&iL&R$0^&xD1HECB(*#*1NoxK+M_KS|tNv^$V(KZ`Kk7SyxwAPd3IQklp-9 zoS(fjpry8}EW^2pJ%N<8^6+!+Rb_~?7ZA_L?713&G9)aN36K3X_a^q*9O9`1nyY7vE zC+MPO;oO;%`+ash*eEJvXJv&w^NxDX=WuYr@813x6aX6bsdrMLKiID-i4JbN*`NLDAoGSi$|0nrV?W6yOGBC2%;Kql|(Yp!C7QZjd#hyWiSG0L~|jUpqCI_q&} z34~Ue;o!A9=y<{(hOMn>bh*Ec>F;^CQi1m_Rqgnt{CZi-bvUPCgX?2&4i=Wp^naI( zjX1Q?vIpp-1scrEKIhT0*4 zoVP!9^c@_9EBNx6<>epQc!YmiQF&In=%c7KMF$~SXPm^feZGOfujbv3xGxQY(pUP)PxX19nEhOmMq zVF69T#;wthr(eWmGlhc%$9*<=1Txi7sj~vCuyN7N5Pt}67Rw;X-a>#01WOI7EC%5d z?N~Z7*=A)+q_@b^ZlRQbmKDI~A8;SS=v%;kmFQK&-R|b=D~hASo5|~Ar+#F{MGC&cDdJav@w1Gh6ePri<$b0%D(j-m?^AF zB7zF7oRp6F8D{mSnx1IcGW9zH`N@eU6|^j{Gs5@&)S8ad2>8#-@F*&ZVasM8v|wzI zwFW1_Na1r*Qm=D`VVO1pO>xvr*Mcx2{wd3j)+At?l)p)I?TYIBJRIdw7lar{s)YKTBASiHTvJ zdRFD{GBR%QsLfU%`=tn63eG=&fhp{Ydf2~Z=2UmF)A-@aE|x*j)xht+Fh)b~Z5{~( ze-syP7B;VVw;m&(!>{7ahRIE&OqMNPn_wVNC3k383;qCd=qZ=<h>v|Id4h zW)1D;Vvp!l<2wbbI?&rIFzT*}hBH_ZK}Uc+@7Qsd%VJ=v#x5lj+E^3=R!YvTc*|5o zVGj!l{M7z~n!CbPE`qUvys<_2FTb##`yKxf<}Ez9`1dCF&BTwH5vvzgV$w`s?AN=| zwEZEO=B03}h|8%b}79faJr_XK)IZWku54@Ve zE$JE3N{H&8IAtTkF^O{|oBa9H5e)In%#1j-Ki0iZom19eA2Eb1XE?=;3J_Ay8nnLM z8_>74bj(2aqkdNcA5>s?(6pSInu^6t$z!ddt*uQP?Y~Wli;l(&a7=dlq z>-fN?Syzwzjgq2$`}XY(>tSzST2RmyE6ebGq{|<`Ih&l?ix)Wc+!)?_esqj*fE@?n z8e+kcw6S49d%+wJWL>YRGUg zY;*2n-1afi)o5jiy+f11uNySGU{di}z{LW>)bQK9DRy9#i6W#E%mD!|_r*U}&lRV5 z5fgWGps=PJV#5^vA1Hdk55Gglfo3We_k6PV3S8aY^-8p|4Zd8mMpbB%h<%IN;TiKE zwvFTj4x&d##1umF_FjYaAJ1C^` z`!~$A4<5qyyeaoJ%z0DX1m^+aLn0mr7>FPtiMx!ka{NkY68wwu$#6$c+|eP>6;d9QZCHzhU}4k$?-8i?75 z3kF~?C)?ydxc9#0Vn?WI`m&Sw)n}nM_WS>JdU|?d(SIx1ySVb$gCs#>BBJX9=-^O0 zyL(bwT}?ApnUiA@h zo19cm7X|nt*}`tn@U^b)^mPBXo0}V4U&qJC-@bh_;_!ej0E#KRJiNSyjqY}b zD}(UB0BYNhv>6|wENO?PA*Ef=nJj(i`J`0vkvEILSvQrRitFF@ zV4VKGzKzXIP+u1GJTTMQIJNQgJcDgMZx6j1F!k9dK@^^e)z9eJPdM8s#3vA%XJTfC z&Icu>rPtteR*K}OjLy$LM9R3hxKu>HFH%GVL57LAh(en1G#DG(8o&?6ZHvfIqd0Tj zW$qAMM16x%8>-pKFP9+^m?xJ;^)-qr6qI{Ha#9i+^N7vn=H?;M3tL~`Z=EK(O&(6_ z>JNDD%KEn2wf`FlFC(LC>*zVu;(VOX7S+STSV6|;BKqn1< z@{Th2-YFy?aR$yITgjy7f-KWfZ(Gu?bavj9guuEO@S_ z_8qhX#D3E_4?Ba6iRq^$H=r)4Q{LFw=^B&I8-4ijA)Iw$G-L4iprLtd`F>K5C7G?P z?#@j0i@Z_Q>(XIMpY@!?y=2%$_4@2UH2eI91n69)RP1^URn-dP4vd)yChdJA7j*$E zZnBp14VF(V>#knYmF6;8PTxcCD~q1dAG^plZmh2-@i|cbuuNb#2;b&O5EvY|6_CPt z8B8w+Fp59l(#nbyD$?wx2El*$@(uT1Eh|xk^GB$Lu;f0!@~vB3#HyUOb;Wor7t?7H z*8N+z*cHk7)9?Pv&~C?}_6RJwAng(Vea1N5o{7`@?SUn@A<^4rSxk()N)` zc^xHR^3!!#b0eVxd%0$}be6y~znm^1OTVC{VQL{kL49^@=J@uUoz2aTLxR{CC(LmQ zv8T@*f8NW92$XB0e%5N4N)>0g#4dXoBp#)_GbPg_7~!0z%` zg-I$Rv#1E?sNc)m8$gQS>T{s!+>bxCGD!qd@X}Mab(iYLaC17UuWid>wwpuXC^VOH zY=1#sDTco7Qo>n)-HnMrQWkATRwkRUBx+}8=T_uh6Lg>3$yf4~ENlNTnAotSS{58? zXA3$prI{bQ7&IAzz^OQ8_O$Jfon6{5S^D@`RU1$AWi<*4ny*8|{#{mOXG;|XH8eEb z0PvFi2 zqCx_?FTIiXB_$=fRrhIAl^DZm+qE# zV+c_lr@%mi!_iwgwH|ryGmOC%Eh`&?7W?p*UeS2C?yu=-U48vXKHA*dVHCQ%Bg$Gr zlu}u$f21-6G0h7`vY(P6`5C(onf3k7v2_z3$vsU{pdaL9PUiR;E55;87$Gj(wpt)L z9quRUi<`o5yE$!4jX7SjtMGu=e1OzV5Gnapl8|N(n{>c+S@i^&c^tR+hX=S4UxM&U z?G;p9=DMwXHax3m%NlYYOzfH7n4U`K|NFcjkR4gUBI$gV;jAto2iZ6J?tfBE{6Yld zNMBMdzwFVX%va#hYV`})EG38%O~(sgI+n0%xA0inLoLSSkI1-VDJp?GKQ;ki$=^#L@Qb0!kc&MerOiQl$!IG%%gRWo`C!*t&wYC)kVPHHtE0|93JsX z(pCYE1(e^gXe*Xs)^8`T2SF zL>Bxo6%3BMGMMG_)?9qY0dTSmnU2@ zIo$1ky+p3iK0DBC@FywI24FJZ{ox{b{A1#@x7E(U;op$o_3i0!(kSNdeW^@I?b(#V zl%R8}LW9Ro6rqZW&n7a0)>DWt%}Ibwij-Mq%V;+nwj>b2etdqXmrVY3*J6NM_(+ZX zd$j4sHX{Tf+qv@T_25{A=7=SCols$C#^1zPlt2jy-R!SRo`n|}aVw&iw{D9{YPIrF zS4+h{i=ofnH}|&uM@2&Uoi;nqnb*{F@hq}FXCPxR^PUzq*;hifKmFWleUCg(S0*dkq+1r#h@EB%TQYorU=G!FG2m~& zg~XLQ3R#PDFhf}&6?zrMAzBDs32LfR;*tt!tGtXK9y>wO0qL!~1SkP#D42JmP~tY@ za+{M!LnP5MJk}H@ZFw;RB_cZCMH?MJ^kjfyxmcWmU3%{^9~HCyB`mP5$)Z?>wXW-5 zoLFLX)-P#pf)7)Ne+J&Vyi@E?YSHs7GwBZ&K1V)wh4W;NYb)(k{1{47B!@rZJ!)G9XvLk>CUpNDi9GZ|3m zb(?UK>~rur1%5|&>^36;D?{%=W_q04MsMIC$ADPS&G*}>=ZFo) ze+JzqvST7(W?~ZWL_x{~ist_O4!fQIO1k_5(0mtHJ#~+^BQj|I#Qs@XNmj@Ozf1-- zhTf`rrlCO)7lkT{x%-qN#Q2)#W*)F^pFE*`j-aeI>&35{*4Nj^<*Ss*O5!vPF)Ygq z)v`1nBNA_UL!Sh8qHHo^&E8qxhMSDP=;1v~R*QZ&M`i^i8f2We8L3PtnUJjbwPSxW z4&h~z3qUY4wF4b}3CWcpJ(iR6C{@W}wg&m%((ir#hNCtu86}+LS;$~;?aSIBj-me& z6~NjL;g|T6``+WTvqj_9cD&?cT(srFl*%D=NPK!iwUsw5AaJq^oTbL8*q z>tiK)0rZr&x3`tmW5as4%?a=>0a%GZ7H8v|-5R|T$`6=1(iQ`smRtp^^|m9Pd|Nv_ z`?>$iq{0V$6NsVkB`K)qDnM@7Z9RE$&!J?Ve(P#2ZEe4^73>sshTm0Lr(V zt)x`40!eBMfEEHB$|6S^>guCFheGE!>j&YPbr7qCG|r>7;Vg)OEW|u|;Z+v9&C?EHrowX>>H>m7Jv3iSBEe*$x8xt*w#N9C~?0N6cGtpKRJ zmn9QM9HC?n7kmzpohA}P5{doQUPsnaQXNl_QMT`>n;>Y`Ll`o9_MXaDy#8mgAw~$P z$y`*4FG6&7a(on z2;wL1ZfVQXpEu$HRl95I6t={1?!lu+P4<)9u6LcdH8t}YLU%5T91IYV^Trg zSl}mNB^RT>5L-zHX`hslX$81k`o=gn<|0f=5`pGg(tXCR7K0|ik)%+`N{*EIu0zAp zyW~i1j>U$hN`qrZ;y9P0lm{G$7fRM$Hs_BS=KF+uzOnmA9k!blFEc_P;CM#CWe_eGdx8@0M%*6@9oa7p=3#A*3Pnv2vg9f(cAjdi^vB zcI5Y&L}PoFDfogh&VT9m@2758aNx$?dGaEJw1!%gh4KTPJSj4UB&1%^=w_@mJt#6I z^4PH!Br7%VZcKWJ_(g~$4gIYwGuNj{1IKl63UO-uNkfcD7;wsAM7SLsy8f`u;Dy;O xtyXx88;cGW30^1=-3)-$Y literal 0 HcmV?d00001 diff --git a/test/emulator/__init__.py b/test/emulator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/emulator/common_emulator.py b/test/emulator/common_emulator.py new file mode 100644 index 0000000..4cc68c1 --- /dev/null +++ b/test/emulator/common_emulator.py @@ -0,0 +1,35 @@ +################################################################################ +# +# Copyright 2023 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ + +from typing import Callable, Tuple, Dict, Any +from functools import wraps + +TestFunc = Callable[[object], object] + + +# wrapper to initialize comms (process group) within emulator +def with_comms_emulator(func: TestFunc) -> TestFunc: + assert func is not None + + @wraps(func) # pyre-ignore[6] + def wrapper(self, *args: Tuple[object], **kwargs: Dict[str, Any]) -> None: # type: ignore[misc] + # launch + self.init_emulator_pg() + func(self, *args, **kwargs) # type: ignore[misc] + self.destroy_emulator_pg() + + return wrapper diff --git a/test/emulator/test_distributed.py b/test/emulator/test_distributed.py new file mode 100644 index 0000000..480b004 --- /dev/null +++ b/test/emulator/test_distributed.py @@ -0,0 +1,212 @@ +################################################################################ +# +# Copyright 2023 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ + + +import os +import torch +import torch.distributed as dist +from torch.testing._internal.common_utils import ( + instantiate_parametrized_tests, + parametrize, + run_tests, +) + +import vescale +from vescale.emulator.distributed import ProcessGroup, dump_nccl_graph_for_pg +from vescale.emulator.reduce_kernel import ReduceOp + +from vescale.emulator.all_gather import expand_tensor_list +from vescale.emulator.reduce_scatter import contract_tensor_list +from common_dtensor import DTensorTestBase, with_comms +from emulator.common_emulator import with_comms_emulator +from vescale.emulator.utils import emulator_reduce_op_to_torch + + +class TestDistributed(DTensorTestBase): + @property + def world_size(self) -> int: + return 4 + + def init_emulator_pg(self): + torch.manual_seed(0) + backend = "nccl" + world_size = self.world_size + + vescale.emulator.distributed.init_process_group(backend=backend, world_size=world_size, rank=0) + vescale.emulator.distributed.set_rank(0) + self.pg: ProcessGroup = vescale.emulator.distributed._world.default_pg + self.torch_pg = torch.distributed.distributed_c10d._get_default_group() + dump_nccl_graph_for_pg(self.pg, self.torch_pg, self.rank) + + def destroy_emulator_pg(self): + vescale.emulator.distributed.destroy_process_group() + + @with_comms + @with_comms_emulator + def test_process_group(self): + ground_truth_pg_group_ranks = [{0: 0, 1: 1, 2: 2, 3: 3}, {0: 0, 2: 1}, {1: 0, 3: 1}, {0: 0, 1: 1}, {2: 0, 3: 1}] + for count, value in enumerate(vescale.emulator.distributed._world.pg_group_ranks.values()): + self.assertEqual(value, ground_truth_pg_group_ranks[count]) + + @with_comms + @with_comms_emulator + # @parametrize("reduce_op", [ReduceOp.SUM, ReduceOp.PRODUCT, ReduceOp.MAX, ReduceOp.MIN]) + @parametrize("reduce_op", [ReduceOp.SUM]) + @parametrize("nelement", [1, 1024, 1024 * 1024]) + def test_all_reduce(self, nelement, reduce_op): + nranks = self.pg.size() + tree_structure = [[0, 1], [2, 3]] + torch_rank = self.rank + device = f"cuda:{torch_rank}" + + input_file = "input_distributed.pt" + if self.rank == 0: + # To ensure all ranks have the same input + input_list = [] + for i in range(nranks): + input_list.append(torch.randn((nelement,), device="cuda")) + torch.save(input_list, input_file) + dist.barrier() + + data_list = torch.load(input_file) + data_list = [data.to(device) for data in data_list] + ground_truth = [data_list[rank].clone().to(device) if rank == torch_rank else [] for rank in range(nranks)] + torch_reduce_op = emulator_reduce_op_to_torch(reduce_op) + + torch.distributed.all_reduce(ground_truth[torch_rank], torch_reduce_op) + self.pg.all_reduce(data_list, op=reduce_op, tree_structure=tree_structure) + + self.assertTrue(torch.equal(data_list[torch_rank], ground_truth[torch_rank])) + + if self.rank == 0: + if os.path.exists(input_file): + os.remove(input_file) + + @with_comms + @with_comms_emulator + @parametrize("nelement", [1, 1024, 1024 * 1024]) + def test_all_gather(self, nelement): + nranks = self.pg.size() + torch_rank = self.rank + device = f"cuda:{torch_rank}" + + input_file = "input_distributed.pt" + if self.rank == 0: + # To ensure all ranks have the same input + input_list = [] + for i in range(nranks): + input_list.append(torch.randn((nelement,), device="cuda")) + torch.save(input_list, input_file) + dist.barrier() + + data_list = torch.load(input_file) + data_list = [data.to(device) for data in data_list] + ground_truth_list = [torch.zeros(nelement).to(device) for _ in range(nranks)] + output_list = expand_tensor_list(data_list) + + torch.distributed.all_gather(ground_truth_list, data_list[torch_rank]) + self.pg.all_gather(output_list, data_list) + + for gt, data in zip(ground_truth_list, data_list): + self.assertTrue(torch.equal(gt, data)) + + if self.rank == 0: + if os.path.exists(input_file): + os.remove(input_file) + + @with_comms + @with_comms_emulator + # @parametrize("reduce_op", [ReduceOp.SUM, ReduceOp.PRODUCT, ReduceOp.MAX, ReduceOp.MIN]) + @parametrize("reduce_op", [ReduceOp.SUM]) + @parametrize("nelement", [1, 1024, 1024 * 1024]) + def test_reduce_scatter(self, nelement, reduce_op): + nranks = self.pg.size() + torch_rank = self.rank + device = f"cuda:{torch_rank}" + + input_file = "input_distributed.pt" + if self.rank == 0: + # To ensure all ranks have the same input + input_list = [] + for i in range(nranks): + input_list.append([]) + for j in range(nranks): + input_list[i].append(torch.randn((nelement,), device="cuda")) + torch.save(input_list, input_file) + dist.barrier() + + data_list = torch.load(input_file) + data_list = [[elem.to(device) for elem in data] for data in data_list] + ground_truth = torch.zeros(nelement).to(device) + outputs = contract_tensor_list(data_list) + torch_reduce_op = emulator_reduce_op_to_torch(reduce_op) + + torch.distributed.reduce_scatter(ground_truth, data_list[torch_rank], torch_reduce_op) + + self.pg.reduce_scatter(outputs, data_list, op=reduce_op) + + result = outputs[torch_rank] + self.assertTrue(torch.equal(result, ground_truth)) + + if self.rank == 0: + if os.path.exists(input_file): + os.remove(input_file) + + @with_comms + @with_comms_emulator + @parametrize("nelement", [1, 1024, 1024 * 1024]) + def test_all_to_all(self, nelement): + nranks = self.pg.size() + torch_rank = self.rank + device = f"cuda:{torch_rank}" + + input_file = "input_distributed.pt" + if self.rank == 0: + # To ensure all ranks have the same input + input_list = [] + for i in range(nranks): + input_list.append([]) + for j in range(nranks): + input_list[i].append(torch.randn((nelement,), device="cuda")) + torch.save(input_list, input_file) + dist.barrier() + + data_list = torch.load(input_file) + outputs_list = [] + ground_truth_list = [] + for i in range(nranks): + outputs_list.append([]) + for j in range(nranks): + data_list[i][j] = data_list[i][j].to(device) + outputs_list[i].append((torch.zeros(nelement)).to(device)) + ground_truth_list.append((torch.zeros(nelement)).to(device)) + + torch.distributed.all_to_all(ground_truth_list, data_list[torch_rank]) + self.pg.all_to_all(outputs_list, data_list) + + for gt, output in zip(ground_truth_list, outputs_list[torch_rank]): + self.assertTrue(torch.equal(gt, output)) + + if self.rank == 0: + if os.path.exists(input_file): + os.remove(input_file) + + +instantiate_parametrized_tests(TestDistributed) + +if __name__ == "__main__": + run_tests() diff --git a/test/emulator/test_dtensor.py b/test/emulator/test_dtensor.py new file mode 100644 index 0000000..ece84e9 --- /dev/null +++ b/test/emulator/test_dtensor.py @@ -0,0 +1,118 @@ +################################################################################ +# Copyright (c) Meta Platforms, Inc. and affiliates +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. +################################################################################ +# Modification Copyright 2023 ByteDance Ltd. and/or its affiliates. +################################################################################ + +import os + +import numpy as np +from common_dtensor import ( + DTensorTestBase, # skip_unless_torch_gpu, + with_comms, +) +from typing import List, cast + +import torch +import torch.distributed as dist +import torch.distributed._functional_collectives as funcol +from torch.testing._internal.common_utils import run_tests + +import vescale +from vescale.dtensor.dtensor import DTensor +from vescale.dtensor.placement_types import Placement, Replicate, Shard + +from vescale.emulator.device_mesh import dump_nccl_graph_for_mesh +from vescale.emulator.distributed import ProcessGroup, dump_nccl_graph_for_pg +from vescale.emulator.comm_api import distribute_tensor, redistribute_dtensor +from vescale.emulator.device_mesh import DeviceMesh +from vescale.emulator.emulator_instrumentation import EmulatorInstrumentation +from emulator.common_emulator import with_comms_emulator + + +class DistMatrixOpsTest(DTensorTestBase): + @property + def world_size(self) -> int: + return 4 + + def init_emulator_pg(self): + torch.manual_seed(0) + backend = "nccl" + world_size = self.world_size + + vescale.emulator.distributed.init_process_group(backend=backend, world_size=world_size, rank=0) + vescale.emulator.distributed.set_rank(0) + # dump default process group + self.pg: ProcessGroup = vescale.emulator.distributed._world.default_pg + self.torch_pg = torch.distributed.distributed_c10d._get_default_group() + dump_nccl_graph_for_pg(self.pg, self.torch_pg, self.rank) + + # dump for other process groups + mesh_tensor = list(range(world_size)) + self.vescale_mesh = vescale.dtensor.device_mesh.DeviceMesh(self.device_type, mesh_tensor) + self.mesh = DeviceMesh(self.device_type, mesh_tensor) + dump_nccl_graph_for_mesh(self.mesh, self.vescale_mesh) + + def destroy_emulator_pg(self): + vescale.emulator.distributed.destroy_process_group() + + @with_comms + @with_comms_emulator + def test_mm(self): + device_mesh = self.mesh + vescale_device_mesh = vescale.dtensor.device_mesh.DeviceMesh(self.device_type, list(range(self.world_size))) + device = f"cuda:{self.rank}" + replica_spec = Replicate() + + input_file = "input_dtensors.pt" + if self.rank == 0: + t1 = torch.randn(12, 8, requires_grad=True).cuda() + t2 = torch.randn(8, 12, requires_grad=True).cuda() + torch.save((t1, t2), input_file) + dist.barrier() + + t1, t2 = torch.load(input_file) + t1 = t1.to(device) + t2 = t2.to(device) + t1_list = [t1.clone().detach().requires_grad_() for _ in range(self.world_size)] + t2_list = [t2.clone().detach().requires_grad_() for _ in range(self.world_size)] + + def test_placement_comb(placements1: List[Placement], placements2: List[Placement]) -> None: + dt1_list = distribute_tensor(t1_list, device_mesh, placements1) + dt2_list = distribute_tensor(t2_list, device_mesh, placements2) + + # Emulator replace the given pytorch function to accpet lists of tensors as input + func_list = ["mm"] + indices = [(0, 1)] + with EmulatorInstrumentation(torch, func_list, indices): + dist_res_list = torch.mm(dt1_list, dt2_list) + dist_res_list = redistribute_dtensor(dist_res_list, device_mesh, [replica_spec]) + + dt1 = vescale.distribute_tensor(t1.clone().detach().requires_grad_(), vescale_device_mesh, placements1) + dt2 = vescale.distribute_tensor(t2.clone().detach().requires_grad_(), vescale_device_mesh, placements2) + dist_res: DTensor = cast(DTensor, torch.mm(dt1, dt2)).redistribute(vescale_device_mesh, [replica_spec]) + + for dist_res_emu in dist_res_list: + self.assertTrue(torch.equal(dist_res.to_local(), dist_res_emu.to_local())) + + shard_specs_comb = [ + (Shard(dim=0), Replicate()), + (Shard(dim=1), Shard(dim=0)), + (Replicate(), Shard(dim=1)), + (Replicate(), Replicate()), + ] + + for spec in shard_specs_comb: + test_placement_comb([spec[0]], [spec[1]]) + + if self.rank == 0: + if os.path.exists(input_file): + os.remove(input_file) + + +if __name__ == "__main__": + run_tests() diff --git a/test/emulator/test_mesh_collectives.py b/test/emulator/test_mesh_collectives.py new file mode 100644 index 0000000..4864d43 --- /dev/null +++ b/test/emulator/test_mesh_collectives.py @@ -0,0 +1,228 @@ +################################################################################ +# +# Copyright 2023 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ + + +import os +import torch +import torch.distributed as dist +from torch.testing._internal.common_utils import ( + instantiate_parametrized_tests, + parametrize, + run_tests, +) + +import vescale +from vescale.emulator.device_mesh import DeviceMesh, dump_nccl_graph_for_mesh +from vescale.emulator.distributed import ProcessGroup, dump_nccl_graph_for_pg +from vescale.emulator.reduce_kernel import ReduceOp +from vescale.emulator.mesh_collectives import mesh_all_gather, mesh_all_reduce, mesh_reduce_scatter, mesh_all_to_all +from common_dtensor import DTensorTestBase, with_comms +from emulator.common_emulator import with_comms_emulator +from vescale.emulator.utils import emulator_reduce_op_to_torch + + +class TestMeshCollectives(DTensorTestBase): + @property + def world_size(self) -> int: + return 4 + + def init_emulator_pg(self): + torch.manual_seed(0) + backend = "nccl" + world_size = self.world_size + dp_size = 2 + tp_size = 2 + + vescale.emulator.distributed.init_process_group(backend=backend, world_size=world_size, rank=0) + vescale.emulator.distributed.set_rank(0) + self.pg: ProcessGroup = vescale.emulator.distributed._world.default_pg + self.torch_pg = torch.distributed.distributed_c10d._get_default_group() + # dump default process group + dump_nccl_graph_for_pg(self.pg, self.torch_pg, self.rank) + + mesh_tensor = torch.tensor(list(range(world_size))).view(dp_size, tp_size) + self.vescale_mesh = vescale.dtensor.device_mesh.DeviceMesh(self.device_type, mesh_tensor) + self.mesh = DeviceMesh(self.device_type, mesh_tensor) + # dump for other process groups + dump_nccl_graph_for_mesh(self.mesh, self.vescale_mesh) + + def destroy_emulator_pg(self): + vescale.emulator.distributed.destroy_process_group() + + @with_comms + @with_comms_emulator + @parametrize("mesh_dim", [0, 1]) + @parametrize("scatter_dim", [0]) + @parametrize("nelement", [1, 1024, 1024 * 1024]) + def test_mesh_all_gather(self, mesh_dim, scatter_dim, nelement): + nranks = self.pg.size() + torch_rank = self.rank + device = f"cuda:{torch_rank}" + + input_file = "input_mesh_coll.pt" + if self.rank == 0: + tensor_list = [torch.randn((nelement,)).to(device) for _ in range(nranks)] + torch.save(tensor_list, input_file) + dist.barrier() + + tensor_list = torch.load(input_file) + tensor_list = [tensor.to(device) for tensor in tensor_list] + + local_tensor = tensor_list[torch_rank] + group = self.vescale_mesh.get_dim_groups(mesh_dim) + group_world_size = torch.distributed.get_world_size(group) + + ground_truth = vescale.dtensor._collective_utils.mesh_all_gather( + local_tensor, [nelement * group_world_size], self.vescale_mesh, scatter_dim, mesh_dim + ) + result = mesh_all_gather(tensor_list, self.mesh, scatter_dim, mesh_dim) + + self.assertTrue(torch.equal(result[torch_rank], ground_truth)) + + if self.rank == 0: + if os.path.exists(input_file): + os.remove(input_file) + + @with_comms + @with_comms_emulator + # @parametrize("reduce_op", [ReduceOp.SUM, ReduceOp.PRODUCT, ReduceOp.MAX, ReduceOp.MIN]) + @parametrize("reduce_op", [ReduceOp.SUM]) + @parametrize("mesh_dim", [0, 1]) + @parametrize("nelement", [1, 1024, 1024 * 1024]) + def test_mesh_all_reduce(self, reduce_op, mesh_dim, nelement): + nranks = self.pg.size() + torch_rank = self.rank + device = f"cuda:{torch_rank}" + tree_structure = [[0, 1, 2, 3]] + + input_file = "input_mesh_coll.pt" + if self.rank == 0: + # To ensure all ranks have the same input + input_list = [] + for i in range(nranks): + input_list.append(torch.randn((nelement,), device="cuda")) + torch.save(input_list, input_file) + dist.barrier() + + tensor_list = torch.load(input_file) + tensor_list = [data.to(device) for data in tensor_list] + ground_truth = [tensor_list[rank].clone().to(device) if rank == torch_rank else [] for rank in range(nranks)] + torch_reduce_op = emulator_reduce_op_to_torch(reduce_op) + + ground_truth[torch_rank] = vescale.dtensor._collective_utils.mesh_all_reduce( + ground_truth[torch_rank], self.vescale_mesh, torch_reduce_op, mesh_dim + ) + result = mesh_all_reduce(tensor_list, self.mesh, reduce_op, mesh_dim, tree_structure=tree_structure) + + self.assertTrue(torch.equal(result[torch_rank], ground_truth[torch_rank])) + + if self.rank == 0: + if os.path.exists(input_file): + os.remove(input_file) + + @with_comms + @with_comms_emulator + # @parametrize("reduce_op", [ReduceOp.SUM, ReduceOp.PRODUCT, ReduceOp.MAX, ReduceOp.MIN]) + @parametrize("reduce_op", [ReduceOp.SUM]) + @parametrize("mesh_dim", [0, 1]) + @parametrize("scatter_dim", [0]) + @parametrize("nelement", [1024, 1024 * 1024]) + def test_mesh_reduce_scatter(self, reduce_op, mesh_dim, scatter_dim, nelement): + nranks = self.pg.size() + torch_rank = self.rank + device = f"cuda:{torch_rank}" + + input_file = "input_mesh_coll.pt" + if self.rank == 0: + # To ensure all ranks have the same input + input_list = [] + for i in range(nranks): + input_list.append(torch.randn((nelement,), device="cuda")) + torch.save(input_list, input_file) + dist.barrier() + + tensor_list = torch.load(input_file) + tensor_list = [data.to(device) for data in tensor_list] + ground_truth = [tensor_list[rank].clone().to(device) if rank == torch_rank else [] for rank in range(nranks)] + torch_reduce_op = emulator_reduce_op_to_torch(reduce_op) + + ground_truth[torch_rank] = vescale.dtensor._collective_utils.mesh_reduce_scatter( + ground_truth[torch_rank], self.vescale_mesh, torch_reduce_op, scatter_dim, mesh_dim + ) + result = mesh_reduce_scatter(tensor_list, self.mesh, reduce_op, scatter_dim, mesh_dim) + + self.assertTrue(torch.equal(result[torch_rank], ground_truth[torch_rank])) + + if self.rank == 0: + if os.path.exists(input_file): + os.remove(input_file) + + @with_comms + @with_comms_emulator + @parametrize("mesh_dim", [0, 1]) + @parametrize("nelement", [1, 1024, 1024 * 1024]) + def test_mesh_all_to_all(self, mesh_dim, nelement): + nranks = self.pg.size() + torch_rank = self.rank + device = f"cuda:{torch_rank}" + + input_file = "input_mesh_coll.pt" + if self.rank == 0: + # To ensure all ranks have the same input + input_list = [] + for i in range(nranks): + input_list.append([]) + for j in range(nranks): + input_list[i].append(torch.randn((nelement,), device="cuda")) + torch.save(input_list, input_file) + dist.barrier() + + data_list = torch.load(input_file) + outputs_list = [] + for i in range(nranks): + outputs_list.append([]) + for j in range(nranks): + data_list[i][j] = data_list[i][j].to(device) + outputs_list[i].append((torch.zeros(nelement)).to(device)) + + local_tensor_list = data_list[torch_rank] + group = self.vescale_mesh.get_dim_groups(mesh_dim) + group_world_size = torch.distributed.get_world_size(group) + ground_truth_list = [torch.zeros(nelement).to(device) for _ in range(nranks)] + + ground_truth_list = [torch.cat(ground_truth_list, dim=0)] + ground_truth_list = list(torch.chunk(ground_truth_list[0], group_world_size, dim=0)) + local_tensor_list = [torch.cat(local_tensor_list, dim=0)] + local_tensor_list = list(torch.chunk(local_tensor_list[0], group_world_size, dim=0)) + + vescale.dtensor._collective_utils.mesh_all_to_all(ground_truth_list, local_tensor_list, self.vescale_mesh, mesh_dim) + mesh_all_to_all(outputs_list, data_list, self.mesh, mesh_dim) + + local_output = outputs_list[torch_rank] + local_output = torch.cat(local_output, dim=0) + ground_truth = torch.cat(ground_truth_list, dim=0) + self.assertTrue(torch.equal(local_output, ground_truth)) + + if self.rank == 0: + if os.path.exists(input_file): + os.remove(input_file) + + +instantiate_parametrized_tests(TestMeshCollectives) + +if __name__ == "__main__": + run_tests() diff --git a/test/emulator/test_topo.py b/test/emulator/test_topo.py new file mode 100644 index 0000000..c81a0b7 --- /dev/null +++ b/test/emulator/test_topo.py @@ -0,0 +1,85 @@ +################################################################################ +# +# Copyright 2023 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ + +import torch +import numpy as np +from torch.testing._internal.common_utils import run_tests + +from vescale.emulator.topo import DoubleTree +from common_dtensor import DTensorTestBase + + +class TestTopo(DTensorTestBase): + @property + def world_size(self) -> int: + return 1 + + def test_get_tree(self): + torch.manual_seed(0) + + tree_structure = np.arange(32).reshape(4, 8) + ranks = [0, 1, 2, 3, 8, 9, 10, 11, 16, 17, 18, 19, 24, 25, 26, 27] + mapping = {} + for i in range(len(ranks)): + mapping[ranks[i]] = i + + tree = DoubleTree(tree_structure, ranks, mapping) + tree_string = [ + [ + "[Rank 0] up: -1, down: [1, -1, -1].\n", + "[Rank 1] up: 0, down: [2, -1, 8].\n", + "[Rank 2] up: 1, down: [3, -1, -1].\n", + "[Rank 3] up: 2, down: [-1, -1, -1].\n", + "[Rank 4] up: 9, down: [5, -1, -1].\n", + "[Rank 5] up: 4, down: [6, -1, -1].\n", + "[Rank 6] up: 5, down: [7, -1, -1].\n", + "[Rank 7] up: 6, down: [-1, -1, -1].\n", + "[Rank 8] up: 1, down: [9, -1, -1].\n", + "[Rank 9] up: 8, down: [10, 4, 12].\n", + "[Rank 10] up: 9, down: [11, -1, -1].\n", + "[Rank 11] up: 10, down: [-1, -1, -1].\n", + "[Rank 12] up: 9, down: [13, -1, -1].\n", + "[Rank 13] up: 12, down: [14, -1, -1].\n", + "[Rank 14] up: 13, down: [15, -1, -1].\n", + "[Rank 15] up: 14, down: [-1, -1, -1].\n", + ], + [ + "[Rank 0] up: 5, down: [1, -1, -1].\n", + "[Rank 1] up: 0, down: [2, -1, -1].\n", + "[Rank 2] up: 1, down: [3, -1, -1].\n", + "[Rank 3] up: 2, down: [-1, -1, -1].\n", + "[Rank 4] up: 13, down: [5, -1, -1].\n", + "[Rank 5] up: 4, down: [6, 8, 0].\n", + "[Rank 6] up: 5, down: [7, -1, -1].\n", + "[Rank 7] up: 6, down: [-1, -1, -1].\n", + "[Rank 8] up: 5, down: [9, -1, -1].\n", + "[Rank 9] up: 8, down: [10, -1, -1].\n", + "[Rank 10] up: 9, down: [11, -1, -1].\n", + "[Rank 11] up: 10, down: [-1, -1, -1].\n", + "[Rank 12] up: -1, down: [13, -1, -1].\n", + "[Rank 13] up: 12, down: [14, -1, 4].\n", + "[Rank 14] up: 13, down: [15, -1, -1].\n", + "[Rank 15] up: 14, down: [-1, -1, -1].\n", + ], + ] + for idx in [0, 1]: + for i in range(len(tree.tree[idx])): + self.assertEqual(str(tree.tree[idx][i]), tree_string[idx][i]) + + +if __name__ == "__main__": + run_tests() diff --git a/vescale/emulator/README.md b/vescale/emulator/README.md new file mode 100644 index 0000000..a8433b8 --- /dev/null +++ b/vescale/emulator/README.md @@ -0,0 +1,220 @@ +# veScale Correctness Emulator +This folder contains **veScale Correctness Emulator** that emulates the results from multiple devices execution on a single device. + +## TLDR +![Emulator overview!](../../docs/pictures/emulator.png) + +## Why veScale Correctness Emulator? +- Modern Frameworks promise **Single-Device Abstraction** for **nD Parallelism**! + - Google's GSPMD + JAX + - PyTorch's torch.titan + - OneFlow + - ByteDance's veScale +- But it is still missing a critical component that can verify the ***correctness*** of **Single-Device Abstraction of nD Parallelism**. + - Difference between the loss curve of single device training and loss curves of 3D parallelism training. + ![Training loss curve mismatch!](../../docs/pictures/training_losses_diff_in_bf16.png) + - How do we know the difference is *correct*? To what extent is it *correct*? + - "Correct" differences come from nD Parallelism + - Communication difference (e.g., ring allreduce) + - Compute difference (e.g., matmul) + - Hardware difference (e.g. FP16) + - "Incorrect" differences come from bugs in + - User configuration + - User model code + - System implementation code + - Data loader + - Model checkpoint + - Random seed and offset + + +## What is veScale Correctness Emulator? + +- **veScale Correctness Emulator** verifies nD prarllelism correctness by emulating nD parallel training on a single device, +- **veScale Correctness Emulator** isolates correctness at different layers and seperates differences come from nD parallelism with differences come from bugs. +- **veScale Correctness Emulator** achieves bitwise correctness in three levels: NCCL collectives, mesh collectives, and DTensor. + +### NCCL Emulation +We are using the NCCL version 2.19.3 code as a reference for our emulation implementation. The code can be found at [NVIDIA/nccl](https://github.com/NVIDIA/nccl/tree/v2.19.3-1). + +**veScale Correctness Emulator** can perfectly emulate NCCL collective APIs' results. This is achieved by implementing the same NCCL collective algorithms and modeling NCCL's computation order via calculating the correct chunk size. + +### Collective APIs Emulation +These are standalone collective APIs which emulate the results from collective APIs of NCCL on a single device. +Supported APIs: +- `all_reduce` +- `all_gather` +- `reduce_scatter` +- `all_to_all` + +### Mesh Collective APIs Emulation +These are standalone mesh collective APIs which emulate the results from mesh collective APIs of PyTorch on a single device. +Supported APIs: +- `mesh_all_reduce` +- `mesh_all_gather` +- `mesh_reduce_scatter` +- `mesh_all_to_all` +- `mesh_broadcast` +- `mesh_scatter` + +### DTensor Redistribution Function Emulation +These are standalone DTensor redistribution functions which emulate the results from DTensor redistribution functions of PyTorch on a single device. +- `R2R` +- `R2S` +- `S2R` +- `P2R` + +Comming soon: A full list of emulator DTensor redistribution functions will be added to support nD parallelisms including DP, TP, SP, PP, EP, and OP. + +## How does veScale Correctness Emulator work? +**veScale Correctness Emulator** achieves bitwise correctness in emulating NCCL collectives APIs results. This is done by implementing the same NCCL collective algorithms and modeling NCCL's algorithm and protocol selection function and chunk size calculation process to ensure the same computation order as NCCL. + +Based on the emulation functions for NCCL collectives, **veScale Correctness Emulator** implements a global-view emulator `ProcessGroup` and `DeviceMesh` that contain all the process groups in the enviroment, while PyTorch's `ProcessGroup` and `DeviceMesh` only view process groups related to the current ranks. + +Aided by the global-view emulator `ProcessGroup` and `DeviceMesh`, **veScale Correctness Emulator** can emulate the results of collective APIs, mesh collective APIs, and DTensor redistribution functions on a single device. + +## How to use veScale Correctness Emulator? +- To achieve bitwise correctness in emulating NCCL collectives APIs results, we need to first dump NCCL's topological graph. It contains important hardware topology information such as `nchannels`, `bwIntra`, `bwInter`, `latencyInter` which is used in accurate chunk size modeling. +```python +import torch.distributed as dist +import vescale.emulator.distributed as emulatordist +import vescale + +# Dump graph for ProcessGroup +dist.init_process_group(backend="nccl", rank=local_rank, world_size=world_size) +emulatordist.init_process_group(backend="nccl", world_size=world_size) +pg = emulatordist._get_default_group() +torch_pg = dist.distributed_c10d._get_default_group() +emulatordist.dump_nccl_graph_for_pg(pg, torch_pg, local_rank) + +# Dump graph for DeviceMesh +vescale_mesh = vescale.dtensor.device_mesh.DeviceMesh(device_type, mesh_tensor) +mesh = vescale.emulator.device_mesh.DeviceMesh(device_type, mesh_tensor) +vescale.emulator.device_mesh.dump_nccl_graph_for_mesh(mesh, vescale_mesh) + +``` + +- Example of emulating standalone NCCL's collective APIs' results +```python +import torch +import vescale.emulator.distributed as dist +from vescale.emulator.reduce_kernel import ReduceOp + +# Initialize an emulator ProcessGroup +dist.init_process_group(backend="nccl", world_size=world_size) + +# Create a list of tensors for the collective operation +data_list = [torch.rand(1024).cuda() for _ in range(world_size)] +reduce_op = ReduceOp.SUM +tree_structure = [[0, 1, 2, 3]] # This 2D list represents one node with four GPUs + +# Perform all_reduce on the list of input tensors +pg = dist._get_default_group() +pg.all_reduce( + data_list, + op=reduce_op, + tree_structure=tree_structure +) +``` +For the complete collective API emulation example, please refer to [collectives unit tests](../../test/emulator/test_distributed.py). + +- Example of emulating standalone mesh collective APIs' results +```python +import torch +from vescale.emulator.device_mesh import DeviceMesh +from vescale.emulator.mesh_collectives import mesh_all_reduce +from vescale.emulator.reduce_kernel import ReduceOp + +# Initialize an emulator DeviceMesh +mesh = DeviceMesh("cuda", [[1, 2], [3, 4]]) + +# Create a list of tensors for the collective operation +data_list = [torch.rand(1024).cuda() for _ in range(world_size)] +reduce_op = ReduceOp.SUM +mesh_dim = 0 +tree_structure = [[0, 1, 2, 3]] # This 2D list represents one node with four GPUs + +# Perform mesh_all_reduce on the list of input tensors +result = mesh_all_reduce( + data_list, + mesh, + reduce_op, + mesh_dim, + tree_structure=tree_structure +) + +``` +For the complete mesh collective API emulation example, please refer to [mesh collectives unit tests](../../test/emulator/test_mesh_collectives.py). + +- Example of emulating DTensor's results from torch function `torch.mm` +```python +from vescale.emulator.device_mesh import DeviceMesh +from vescale.emulator.comm_api import distribute_tensor, redistribute_dtensor +from vescale.emulator.emulator_instrumentation import EmulatorInstrumentation + +# Initialize an emulator DeviceMesh +mesh = DeviceMesh("cuda", [[1, 2, 3, 4]]) + +# Create a list of DTensor with coresponding placement from a list of Tensor +dt1_list = distribute_tensor(t1_list, device_mesh, [Shard(dim=1)]) +dt2_list = distribute_tensor(t2_list, device_mesh, [Shard(dim=0)]) + +# Replace all the given torch functions by wrapper functions that iterate +# the torch functions on the lists of DTensor arguments +func_list = ["mm"] +indices = [(0, 1)] +with EmulatorInstrumentation(torch, func_list, indices): + # Shard(dim=1) multipled by Shard(dim=0) and gets Partial() + dist_res_list = torch.mm(dt1_list, dt2_list) + # Calls P2R redistribution function to get Replicate() + dist_res_list = redistribute_dtensor(dist_res_list, device_mesh, [Replicate()]) + + +``` +For the complete DTensor emulation example, please refer to [DTensor unit tests](../../test/emulator/test_dtensor.py). + + +## veScale Correctness Emulator Bitwise Match Results +- Bitwise comparison between the results of the emulator's `all_reduce` operation and PyTorch's `all_reduce` operation across 4 GPUs, using randomly generated tensors of the `torch.float32` data type with varying sizes. The comparison includes the `min`, `max`, and `avg` values of the resulting tensors to evaluate the bitwise equivalence of the operations. + - Figure: + + ![Emulator distributed!](../../docs/pictures/emulator_distributed.png) + - Result: + +| tensor size | 4B | 2KB | 4KB | 4MB | 2GB | +| ------------ | ------------------------- | ------------------------- | ------------------------- | ------------------------- | ------------------------- | +| emulator-min | 2.44264364242553710937500 | 0.45483720302581787109375 | 0.31362706422805786132812 | 0.07745391130447387695312 | 0.00967526435852050781250 | +| emulator-max | 2.44264364242553710937500 | 3.51578140258789062500000 | 3.47223615646362304687500 | 3.94559144973754882812500 | 3.98580169677734375000000 | +| emulator-avg | 2.44264364242553710937500 | 2.00266790390014648437500 | 1.94976377487182617187500 | 2.00139284133911132812500 | 2.00001406669616699218750 | +| pytorch-min | 2.44264364242553710937500 | 0.45483720302581787109375 | 0.31362706422805786132812 | 0.07745391130447387695312 | 0.00967526435852050781250 | +| pytorch-max | 2.44264364242553710937500 | 3.51578140258789062500000 | 3.47223615646362304687500 | 3.94559144973754882812500 | 3.98580169677734375000000 | +| pytorch-avg | 2.44264364242553710937500 | 2.00266790390014648437500 | 1.94976377487182617187500 | 2.00139284133911132812500 | 2.00001406669616699218750 | + +- Bitwise comparison between the emulator's `mesh_all_reduce` and veScale's `mesh_all_reduce` operations on 4 GPUs. The experiments were executed using a `DeviceMesh` configuration with `TP=2` and `DP=2`, performing `mesh_all_reduce` along mesh dimension 0. The input data consisted of randomly generated tensors of varying sizes in the `torch.float32` data type. We present the `min`, `max`, and `avg` values of the resulting tensors to evaluate the bitwise consistency of the operations. + - Figure: + + ![Emulator mesh collective!](../../docs/pictures/emulator_mesh_collectives.png) + - Result + +| tensor size | 4B | 2KB | 4KB | 4MB | 2GB | +| ------------ | ------------------------- | ------------------------- | ------------------------- | ------------------------- | ------------------------- | +| emulator-min | 1.12971544265747070312500 | 0.03645843267440795898438 | 0.02600371837615966796875 | 0.00088638067245483398438 | 0.00005841255187988281250 | +| emulator-max | 1.12971544265747070312500 | 1.94575321674346923828125 | 1.97041249275207519531250 | 1.99889516830444335937500 | 1.99988579750061035156250 | +| emulator-avg | 1.12971544265747070312500 | 0.96918821334838867187500 | 1.00050532817840576171875 | 1.00046503543853759765625 | 1.00000810623168945312500 | +| pytorch-min | 1.12971544265747070312500 | 0.03645843267440795898438 | 0.02600371837615966796875 | 0.00088638067245483398438 | 0.00005841255187988281250 | +| pytorch-max | 1.12971544265747070312500 | 1.94575321674346923828125 | 1.97041249275207519531250 | 1.99889516830444335937500 | 1.99988579750061035156250 | +| pytorch-avg | 1.12971544265747070312500 | 0.96918821334838867187500 | 1.00050532817840576171875 | 1.00046503543853759765625 | 1.00000810623168945312500 | + +- Bitwise comparison between the emulator's `DTensor` redistribution and veScale's `DTensor` redistribution on 4 GPUs. The experiment involved executing `torch.mm` on two DTensors, where the first DTensor was sharded along dimension 1 (`Shard(dim=1)`) and the second along dimension 0 (`Shard(dim=0)`). The results of `the torch.mm` operation were then redistributed from `Partial()` to `Replicate()` using `mesh_all_reduce`. We present the `min`, `max`, and `avg` values of the resulting tensors to assess the bitwise consistency of the redistribution. + - Figure: + + ![Emulator DTensor!](../../docs/pictures/emulator_dtensor.png) + - Result: + +| tensor size | 64B | 64KB | 64MB | 128MB | +| ------------ | ------------------------- | -------------------------- | --------------------------- | ---------------------------- | +| emulator-min | 0.59677535295486450195312 | 9.33347034454345703125000 | 456.83691406250000000000000 | 1936.85644531250000000000000 | +| emulator-max | 1.42116737365722656250000 | 23.44433212280273437500000 | 567.39196777343750000000000 | 2168.15747070312500000000000 | +| emulator-avg | 0.98043894767761230468750 | 16.13499450683593750000000 | 512.05310058593750000000000 | 2048.04589843750000000000000 | +| pytorch-min | 0.59677535295486450195312 | 9.33347034454345703125000 | 456.83691406250000000000000 | 1936.85644531250000000000000 | +| pytorch-max | 1.42116737365722656250000 | 23.44433212280273437500000 | 567.39196777343750000000000 | 2168.15747070312500000000000 | +| pytorch-avg | 0.98043894767761230468750 | 16.13499450683593750000000 | 512.05310058593750000000000 | 2048.04589843750000000000000 | \ No newline at end of file diff --git a/vescale/emulator/__init__.py b/vescale/emulator/__init__.py new file mode 100644 index 0000000..bb89fa3 --- /dev/null +++ b/vescale/emulator/__init__.py @@ -0,0 +1,18 @@ +################################################################################ +# +# Copyright 2023 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ + +VESCALE_EMULATE = 1 diff --git a/vescale/emulator/all_gather.py b/vescale/emulator/all_gather.py new file mode 100644 index 0000000..8800ea9 --- /dev/null +++ b/vescale/emulator/all_gather.py @@ -0,0 +1,103 @@ +################################################################################ +# +# Copyright 2023 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ +# Some code comes from all_gather.cc in NCCL +# Original license: +# Copyright (c) 2016-2022, NVIDIA CORPORATION. All rights reserved. +# +# See LICENSE.txt for license information +################################################################################ + +from typing import List +import torch + +from vescale.emulator.topo import Ring +from vescale.emulator.primitives import RingPrimitive + + +def expand_tensor_list(tensor_list: List[torch.Tensor]) -> None: + """ + Expand a list of tensors into a list of tensors with the same size but with the first dimension + expanded to be the product of the sizes of the original tensors. + """ + n = len(tensor_list) + a = tensor_list[0].size(0) + + # Create a list to hold the expanded tensors + expanded_list = [] + + for i in range(n): + # Create a new tensor of size (n * a) filled with zeros + expanded_tensor = torch.zeros(n * a, dtype=tensor_list[i].dtype, device=tensor_list[i].device) + + # Copy the ith original tensor to the ith segment of the expanded tensor + expanded_tensor[i * a : (i + 1) * a] = tensor_list[i] + + # Add the expanded tensor to the list + expanded_list.append(expanded_tensor) + + return expanded_list + + +def run_ring_all_gather( + data_list: List[torch.Tensor], + ranks: List[int], + device: torch.device, + chunk_count: int, + part_count: int, + part_offset: int, +) -> List[torch.Tensor]: + """ + Run a ring all-gather operation on the given data_list. + + Args: + data_list: List of tensors to be gathered. + ranks: List of ranks in the communication. + device: Device to run the operation on. + chunk_count: Size of chunks in the communication. + part_count: Total size of elements in a rank to be gathered in the communication. + part_offset: Offset of the data. + + Returns: + List of tensors with the gathered data. + """ + count = len(data_list[0]) + data_list = expand_tensor_list(data_list) + + ring = Ring(ranks) + prims = RingPrimitive(data_list, ring, device=device) + + for elem_offset in range(0, part_count, chunk_count): + nelem = min(chunk_count, part_count - elem_offset) + data_offset = part_offset + elem_offset + + for ring_idx in range(ring.nranks): + rank_dest = ring_idx + offset = data_offset + rank_dest * count + prims.send(ring_idx, offset, nelem) + + for j in range(1, ring.nranks - 1, 1): + for ring_idx in range(ring.nranks): + rank_dest = ring.mod_rank(ring_idx + ring.nranks - j) + offset = data_offset + rank_dest * count + prims.direct_recv_copy_send(ring_idx, offset, nelem) + + for ring_idx in range(ring.nranks): + rank_dest = ring.mod_rank(ring_idx + 1) + offset = data_offset + rank_dest * count + prims.direct_recv(ring_idx, offset, nelem) + + return prims.convert_to_original_device_and_datatype() diff --git a/vescale/emulator/all_reduce.py b/vescale/emulator/all_reduce.py new file mode 100644 index 0000000..419c032 --- /dev/null +++ b/vescale/emulator/all_reduce.py @@ -0,0 +1,347 @@ +################################################################################ +# +# Copyright 2023 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ +# Some code comes from all_reduce.cc in NCCL +# Original license: +# Copyright (c) 2016-2022, NVIDIA CORPORATION. All rights reserved. +# +# See LICENSE.txt for license information +################################################################################ + +from typing import List +import torch + +from vescale.emulator.nccl.include.info import NcclInfo +from vescale.emulator.reduce_kernel import ReduceOp +from vescale.emulator.topo import Ring, DoubleTree +from vescale.emulator.primitives import RingPrimitive, TreePrimitive +from vescale.emulator.calculate_chunk_size import calcBytePerStep, calcBytePerGrain, compute_last_chunk_size +from vescale.emulator.nccl.constants import ( + NCCL_PROTO_LL, + NCCL_PROTO_LL128, + NCCL_PROTO_SIMPLE, +) +from vescale.emulator.nccl.constants import * # noqa: F403 + + +def run_ring_all_reduce( + info: NcclInfo, + nchannels: int, + nwarps: int, + protocol: int, + data_list: List[torch.Tensor], + ranks: List[int], + device: torch.device, + channel_count: int, + grid_offset: int, + reduce_op: ReduceOp, +) -> List[torch.Tensor]: + """ + Run a ring all-reduce operation on the given data_list. This function can be regarded + as a ring reduce_scatter followed by a ring all-gather. + + Args: + info: NcclInfo object containing information about the communication. + nchannels: Number of channels in the communication. + nwarps: Number of warps in the kernel. + protocol: Protocol to use for communication. + data_list: List of tensors to be reduced. + ranks: List of ranks in the communication. + device: Device to run the operation on. + channel_count: Size of elements each channel communicates in an iteration. + grid_offset: Offset of the data. + reduce_op: Reduction operation to perform. + + Returns: + List of tensors with the reduced data. + """ + ring = Ring(ranks) + prims = RingPrimitive(data_list, ring, reduce_op, device) + + nthreads = nwarps * WARP_SIZE + + sizeof_T = data_list[0].element_size() + + chunk_count = int( + calcBytePerStep(protocol, info.comm) / sizeof_T * (ALLREDUCE_CHUNKSTEPS if protocol == NCCL_PROTO_SIMPLE else 1) + ) + loop_count = nchannels * ring.nranks * chunk_count + + min_chunk_count = 0 + if protocol == NCCL_PROTO_LL: + min_chunk_count = nthreads * (calcBytePerGrain(protocol) / sizeof_T) + elif protocol == NCCL_PROTO_LL128: + min_chunk_count = nthreads * (calcBytePerGrain(protocol) / sizeof_T) / 2 + + count = 0 + for elem_offset in range(0, channel_count, loop_count): + if protocol == NCCL_PROTO_SIMPLE: + real_chunk_count = min(chunk_count, div_up(channel_count - elem_offset, nchannels * ring.nranks)) + real_chunk_count = round_up(real_chunk_count, (nthreads - WARP_SIZE) * sizeof_uint64_t / sizeof_T) + else: + real_chunk_count = min( + chunk_count, + div_up(channel_count - elem_offset, nchannels * ring.nranks * min_chunk_count) * min_chunk_count, + ) + real_chunk_count = int(real_chunk_count) + for bid in range(nchannels): + + def calc_offset(chunk): + if protocol == NCCL_PROTO_SIMPLE: + return elem_offset + bid * ring.nranks * real_chunk_count + chunk * real_chunk_count + else: + return elem_offset + (chunk * nchannels + bid) * real_chunk_count + + # ring reduce_scatter starts + for ring_idx in range(ring.nranks): + chunk = ring.mod_rank(ring_idx + ring.nranks - 1) + offset = calc_offset(chunk) + nelem = min(real_chunk_count, channel_count - offset) + prims.send(ring_idx, offset, nelem) + + for j in range(2, ring.nranks, 1): + for ring_idx in range(ring.nranks): + chunk = ring.mod_rank(ring_idx + ring.nranks - j) + offset = calc_offset(chunk) + nelem = min(real_chunk_count, channel_count - offset) + prims.recv_reduce_send(ring_idx, offset, nelem) + + # ring reduce_scatter ends and ring all-gather starts + for ring_idx in range(ring.nranks): + chunk = ring_idx + offset = calc_offset(chunk) + nelem = min(real_chunk_count, channel_count - offset) + prims.direct_recv_reduce_copy_send(ring_idx, offset, nelem) + + for j in range(1, ring.nranks - 1, 1): + for ring_idx in range(ring.nranks): + chunk = ring.mod_rank(ring_idx + ring.nranks - j) + offset = calc_offset(chunk) + nelem = min(real_chunk_count, channel_count - offset) + prims.direct_recv_copy_send(ring_idx, offset, nelem) + + for ring_idx in range(ring.nranks): + chunk = ring.mod_rank(ring_idx + 1) + offset = calc_offset(chunk) + nelem = min(real_chunk_count, channel_count - offset) + prims.direct_recv(ring_idx, offset, nelem) + # ring all-gather ends + + return prims.convert_to_original_device_and_datatype() + + +def run_tree_up_down( + info: NcclInfo, + nchannels: int, + nwarps: int, + protocol: int, + data_list: List[torch.Tensor], + tree_structure: List[List[int]], + ranks: List[int], + mapping: List[int], + mode: int, + device: torch.device, + channel_count: int, + grid_offset: int, + reduce_op: ReduceOp, + tree_idx: int, +) -> List[torch.Tensor]: + """ + Run a single tree all-reduce operation on the given data_list. This function can be regarded + as a tree reduce followed by a tree broadcast. + + Args: + info: NcclInfo object containing information about the communication. + nchannels: Number of channels in the communication. + nwarps: Number of warps in the kernel. + protocol: Protocol to use for communication. + data_list: List of tensors to be reduced. + tree_structure: Tree structure of the servers. + ranks: List of ranks in the communication. + mapping: Mapping of ranks to nodes in the tree. + mode: Mode of the communication. + device: Device to run the operation on. + channel_count: Size of elements each channel communicates in an iteration. + grid_offset: Offset of the data. + reduce_op: Reduction operation to perform. + tree_idx: Index of the tree. + + Returns: + List of tensors with the reduced data. + """ + tree = DoubleTree(tree_structure, ranks, mapping, pattern=mode) + prims = TreePrimitive(data_list, tree, reduce_op, device) + + sizeof_T = data_list[0].element_size() + nthreads = nwarps * WARP_SIZE + last_chunk_count = compute_last_chunk_size(info) + if protocol == NCCL_PROTO_SIMPLE: + chunk_count = int(last_chunk_count) + min_chunk_count = int((nthreads - 2 * WARP_SIZE) * 8 * (sizeof_uint64_t / sizeof_T)) + else: + chunk_count = int(calcBytePerStep(protocol, info.comm) / sizeof_T) + min_chunk_count = int(nthreads * (calcBytePerGrain(protocol) / sizeof_T)) + + loopsize = int(nchannels * chunk_count) + size = data_list[0].size()[0] + + if loopsize > size: + chunk_count = div_up(int(size), int(nchannels * min_chunk_count)) * int(min_chunk_count) + + def get_root(tree_idx): + node = 0 + while tree.tree[tree_idx][node].up != -1: + node = tree.tree[tree_idx][node].up + return node + + def tree_reduce_helper(rank, offset, nelem): + for d in tree.tree[tree_idx][rank].down: + if d != -1: + tree_reduce_helper(d, offset, nelem) + if tree.tree[tree_idx][rank].up == -1: + prims.recv_reduce_copy(rank, tree_idx, offset, nelem) + elif all(d == -1 for d in tree.tree[tree_idx][rank].down): + prims.send(rank, tree_idx, offset, nelem) + else: + prims.recv_reduce_send(rank, tree_idx, offset, nelem) + + def tree_broadcast_helper(rank, offset, nelem): + if tree.tree[tree_idx][rank].up == -1: + prims.direct_send_from_output(rank, tree_idx, offset, nelem) + elif all(d == -1 for d in tree.tree[tree_idx][rank].down): + prims.direct_recv(rank, tree_idx, offset, nelem) + else: + prims.direct_recv_copy_send(rank, tree_idx, offset, nelem) + for d in tree.tree[tree_idx][rank].down: + if d != -1: + tree_broadcast_helper(d, offset, nelem) + + # tree reduce + root = get_root(tree_idx) + elem_offset = 0 + while elem_offset < channel_count: + offset = grid_offset + elem_offset + nelem = min(chunk_count, channel_count - elem_offset) + tree_reduce_helper(root, offset, nelem) + elem_offset += chunk_count + + # tree broadcast + elem_offset = 0 + while elem_offset < channel_count: + offset = grid_offset + elem_offset + nelem = min(chunk_count, channel_count - elem_offset) + tree_broadcast_helper(root, offset, nelem) + elem_offset += chunk_count + + return prims.data_list[tree_idx] + + +def split_tensors(tensor_list: List[torch.Tensor]): + """ + Split a list of tensors into two lists of tensors, each containing half of the original tensors. + """ + list1 = [] + list2 = [] + + for tensor in tensor_list: + size = tensor.size(0) + half_size = size // 2 + + tensor1 = tensor[:half_size] + tensor2 = tensor[half_size:] + + list1.append(tensor1) + list2.append(tensor2) + + return list1, list2 + + +def concatenate_tensors(list1: List[torch.tensor], list2: List[torch.tensor]): + """ + Concatenate two lists of tensors along the first dimension. + """ + concatenated_list = [] + + # Check if both lists are of the same length + assert len(list1) == len(list2), "Both lists must have the same length" + + for tensor1, tensor2 in zip(list1, list2): + # Concatenate the tensors along the first dimension + concatenated_tensor = torch.cat((tensor1, tensor2), dim=0) + concatenated_list.append(concatenated_tensor) + + return concatenated_list + + +def run_tree_all_reduce( + info: NcclInfo, + nchannels: int, + nwarps: int, + protocol: int, + data_list: List[torch.Tensor], + tree_structure: List[List[int]], + ranks: List[int], + mapping: List[int], + mode: int, + device: torch.device, + channel_count: int, + grid_offset: int, + reduce_op: ReduceOp, +) -> List[torch.Tensor]: + """ + Run a double tree all-reduce operation on the given data_list. + + Args: + info: NcclInfo object containing information about the communication. + nchannels: Number of channels in the communication. + nwarps: Number of warps in the kernel. + protocol: Protocol to use for communication. + data_list: List of tensors to be reduced. + tree_structure: Tree structure of the servers. + ranks: List of ranks in the communication. + mapping: Mapping of ranks to nodes in the tree. + mode: Mode of the communication. + device: Device to run the operation on. + channel_count: Size of elements each channel communicates in an iteration. + grid_offset: Offset of the data. + reduce_op: Reduction operation to perform. + + Returns: + List of reduced tensors. + """ + tensor_list_half_list = split_tensors(data_list) + result_list_half_list = [] + for i in range(2): + result_list_half_list.append( + run_tree_up_down( + info, + nchannels, + nwarps, + protocol, + tensor_list_half_list[i], + tree_structure, + ranks, + mapping, + mode, + device, + tensor_list_half_list[i][0].size()[0], + grid_offset, + reduce_op, + i, + ) + ) + result_list = concatenate_tensors(result_list_half_list[0], result_list_half_list[1]) + return result_list diff --git a/vescale/emulator/all_to_all.py b/vescale/emulator/all_to_all.py new file mode 100644 index 0000000..0670076 --- /dev/null +++ b/vescale/emulator/all_to_all.py @@ -0,0 +1,78 @@ +################################################################################ +# +# Copyright 2023 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ + +from typing import List + +import torch +from vescale.emulator.primitives import Point2PointPrimitive + + +def run_all_to_all( + data_list: List[torch.tensor], + ranks: List[int], + device: torch.device, + datatype: torch.dtype, + chunk_count: int, + part_count: int, + part_offset: int, +) -> List[torch.tensor]: + """ + Run a all-to-all operation on the given data_list. This function calls a list of send + and recv primitives to send and receive data between ranks. + + Args: + data_list: List of tensors to be sent. + ranks: List of ranks in the communication. + device: Device to run the operation on. + datatype: Data type of the tensor. + chunk_count: Size of chunks in the communication. + part_count: Total size of elements in a rank to be sent in the communication. + part_offset: Offset of the data. + + Returns: + List of tensors with the gathered data. + + """ + group_ranks = list(range(len(ranks))) + prims = Point2PointPrimitive(data_list, group_ranks, device=device, datatype=datatype) + count = len(data_list[0]) // len(group_ranks) + + for src_rank in group_ranks: + for dst_rank in group_ranks: + send_offset = dst_rank * count + recv_offset = src_rank * count + cursor = 0 + while cursor < part_count: + n = min(chunk_count, part_count - cursor) + src_offset = part_offset + send_offset + cursor + dst_offset = part_offset + recv_offset + cursor + prims.send(src_rank, src_offset, n, datatype, dst_rank, dst_offset) + cursor += n + + for src_rank in group_ranks: + for dst_rank in group_ranks: + send_offset = dst_rank * count + recv_offset = src_rank * count + cursor = 0 + while cursor < part_count: + n = min(chunk_count, part_count - cursor) + src_offset = part_offset + send_offset + cursor + dst_offset = part_offset + recv_offset + cursor + prims.recv(src_rank, src_offset, n, datatype, dst_rank, dst_offset) + cursor += n + + return prims.convert_to_original_device_and_datatype() diff --git a/vescale/emulator/calculate_chunk_size.py b/vescale/emulator/calculate_chunk_size.py new file mode 100644 index 0000000..0934ef3 --- /dev/null +++ b/vescale/emulator/calculate_chunk_size.py @@ -0,0 +1,154 @@ +################################################################################ +# +# Copyright 2023 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ + + +from vescale.emulator.nccl.constants import * # noqa: F403 +from vescale.emulator.nccl.include.graph import NCCL_TOPO_PATTERN_TREE +from vescale.emulator.nccl.include.info import NcclInfo +from vescale.emulator.nccl.init import init +from vescale.emulator.nccl.nccl_profiler_result import parse_nccl_topo + + +def topo_get_algo_info(info: NcclInfo, nchannels: int, algo: int, proto: int, nranks: int): + info_nchannels = info.nChannels + comm_nchannels = nchannels + nc = info_nchannels if info_nchannels > 0 else comm_nchannels + nt = info.comm.max_threads[algo][proto] + thread_threshold = info.comm.thread_thresholds[algo][proto] + + info_nBytes = info.nBytes + + while info_nBytes < nc * nt * thread_threshold: + if nc >= 2: + nc -= 1 + else: + if (nt % 128) == 0: + nt /= 2 + else: + break + if proto == NCCL_PROTO_SIMPLE: + if algo == NCCL_ALGO_RING: + nt += WARP_SIZE + if algo == NCCL_ALGO_TREE: + nt += 4 * WARP_SIZE + nt = 3 * WARP_SIZE if nt / WARP_SIZE < 3 else nt + return nc, nt + + +def get_info_nchannels_nthreads_proto(pg, coll, count, dtype, nranks, nnodes): + graphs, nchannels, minCompCap, maxCompCap = parse_nccl_topo(pg) + info = init(coll, count, dtype, nchannels, nnodes, nranks, minCompCap, maxCompCap, graphs) + + nchannels, nthreads = topo_get_algo_info(info, nchannels, info.algorithm, info.protocol, nranks) + info.nChannels = nchannels + info.nThreads = nthreads + return info, nchannels, nthreads, info.protocol + + +def calcBytePerStep(id, comm): + if id == NCCL_PROTO_SIMPLE: + return comm.buff_sizes[NCCL_PROTO_SIMPLE] / NCCL_STEPS + elif id == NCCL_PROTO_LL: + return comm.buff_sizes[NCCL_PROTO_LL] / NCCL_STEPS / 2 + else: + return (comm.buff_sizes[NCCL_PROTO_LL128] / NCCL_STEPS) * NCCL_LL128_DATAELEMS / NCCL_LL128_LINEELEMS + + +def calcBytePerGrain(id): + if id == NCCL_PROTO_SIMPLE: + return sizeof_uint64_t + elif id == NCCL_PROTO_LL: + return sizeof_uint64_t + else: + return NCCL_LL128_SHMEM_ELEMS_PER_THREAD * NCCL_LL128_DATAELEMS * sizeof_uint64_t / NCCL_LL128_LINEELEMS + + +def get_pattern_info(info: NcclInfo): + if info.coll == NcclFunc.ncclFuncReduceScatter: + info.pattern = NcclPattern.Ring + elif info.coll == NcclFunc.ncclFuncAllReduce: + if info.algorithm == NCCL_ALGO_TREE: + info.pattern = NcclPattern.TreeUpDown + else: + info.pattern = NcclPattern.RingTwice + else: + raise ValueError(f"Unsupported collective: {info.coll}") + return info + + +def get_loop_info(info: NcclInfo): + if info.pattern == NcclPattern.TreeUpDown: + info.nstepsPerLoop = 1 + info.nchunksPerLoop = 1 + elif info.pattern == NcclPattern.Ring: + info.nstepsPerLoop = info.comm.nRanks - 1 + info.nchunksPerLoop = info.comm.nRanks + elif info.pattern == NcclPattern.RingTwice: + info.nstepsPerLoop = 2 * (info.comm.nRanks - 1) + info.nchunksPerLoop = info.comm.nRanks + else: + raise ValueError(f"Unsupported pattern: {info.pattern}") + return info + + +def compute_last_chunk_size(info: NcclInfo): + nNodes = info.comm.nNodes + depth = info.comm.nRanks / nNodes - 1 + log2i(nNodes) + + info = get_pattern_info(info) + info = get_loop_info(info) + + stepSize = info.comm.buff_sizes[info.protocol] / NCCL_STEPS + if info.protocol == NCCL_PROTO_SIMPLE and info.algorithm == NCCL_ALGO_RING: + chunkSteps = info.chunkSteps + sliceSteps = info.sliceSteps + else: + chunkSteps = 1 + sliceSteps = 1 + chunkSize = stepSize * chunkSteps + + lastChunkSize = 0 + + if info.algorithm == NCCL_ALGO_TREE and info.protocol == NCCL_PROTO_SIMPLE: + if info.pattern == NCCL_TOPO_PATTERN_TREE: + # Optimize chunkSize / nSteps + while info.nBytes / (info.nChannels * chunkSize) < depth * 8 and chunkSize > 131072: + chunkSize /= 2 + while info.nBytes / (info.nChannels * chunkSize) < depth * 4 and chunkSize > 65536: + chunkSize /= 2 + while info.nBytes / (info.nChannels * chunkSize) < depth and chunkSize > 32768: + chunkSize /= 2 + # Use lastChunkSize as chunkSize + lastChunkSize = chunkSize / info.datatype.itemsize + elif info.protocol == NCCL_PROTO_LL: + sliceSize = stepSize * sizeof_uint64_t / sizeof_union_ncclLLFifoLine + loopSize = info.nChannels * info.nchunksPerLoop * sliceSize + lastChunkSize = div_up( + (info.nBytes - (info.nBytes // loopSize) * loopSize), info.nChannels * info.nchunksPerLoop + ) + ALIGN_SIZE(lastChunkSize, info.nThreads * sizeof_uint64_t) + lastChunkSize /= info.datatype.itemsize + elif info.algorithm == NCCL_ALGO_TREE and info.protocol == NCCL_PROTO_LL128: + nNodes = info.comm.nNodes + ppn = info.comm.nRanks / nNodes + nstepsLL128 = 1 + log2i(nNodes) + 0.1 * ppn + while (info.nBytes / (info.nChannels * chunkSize) < nstepsLL128 * 64 / ppn) and (chunkSize > 131072): + chunkSize /= 2 + while (info.nBytes / (info.nChannels * chunkSize) < nstepsLL128 * 16 / ppn) and (chunkSize > 32768): + chunkSize /= 2 + lastChunkSize = chunkSize * NCCL_LL128_DATAELEMS // (NCCL_LL128_LINEELEMS * info.datatype.itemsize) + return lastChunkSize diff --git a/vescale/emulator/comm_api.py b/vescale/emulator/comm_api.py new file mode 100644 index 0000000..85e21f7 --- /dev/null +++ b/vescale/emulator/comm_api.py @@ -0,0 +1,342 @@ +################################################################################ +# +# Copyright 2023 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ + +from typing import List, Optional, Sequence, Tuple +import torch +from torch.autograd.function import _SingleLevelFunction +from torch._subclasses.fake_tensor import FakeTensorMode + +import vescale.dtensor.dtensor as dtensor +from vescale.dtensor.placement_types import DTensorSpec, Placement, Replicate, TensorMeta +from vescale.dtensor.api import normalize_placements +from vescale.dtensor.dtensor import DTensor + +from vescale.emulator.device_mesh import DeviceMesh, mesh_resources +from vescale.emulator.comm_primitive import ( + get_redistribute_fn, + R2R, + R2S, +) + + +def redistribute_local_tensor( + local_tensors: List[torch.Tensor], + current_spec: DTensorSpec, + target_spec: DTensorSpec, + async_op: bool = True, + reverse: bool = False, + emit_comm: bool = False, + fake: bool = False, +) -> List[torch.Tensor]: + """ + This redistribute the list of local tensors (List[torch.Tensor]) from the current DTensorSpec to + the target DTensorSpec, which involves the necessary emulator collective calls to transform + the local shard of the DTensor from its current spec to the target spec. + + Args: + current_spec (DTensorSpec): sharding info of the local_tensor. + target_spce (DTensorSpec): sharding info of the desired tensor after communicating. + async_op (bool, optional): whether this redistribute is asynchronous in communication (for both forward and backward). + This argument is ignored. + reverse (bool, optional): the order in which communication happens on mesh dims. + - False: the default value, communicate tensor from outter mesh dims to inner mesh dims, + i.e, preferentially communicating meshes at lower ranks + - True: communicate tensor from inner mesh dims first. + Note, in most cases, reverse=True or False has no impact on the final output tensor. + But be careful when there are multi sharding on one tensor dim. + emit_comm (bool, optional): whether to emit collective when converting tensor from Replicate spec to other spec. + If you are not sure that source tensors are the same for all ranks, set emit_comm = True. + For example, if you are converting tensor from Replicate to Shard, when you provide emit_comm = True, + it will emit mesh_scatter collective, otherwise it will simply split the original tensor and take a part as the result. + fake (bool, optional): whether to run in FakeTensorMode. Default, False. + + .. Note:: + - You shouldn't assume tensormeta exists. + - Not differentiable + """ + + if current_spec.mesh != target_spec.mesh: + current_spec = current_spec.cast_to(target_spec.mesh) + if current_spec is None: + # TODO: alltoall/permute reshuffling to change device_mesh if they are not the same + raise NotImplementedError( + "Cross device mesh communication not supported when DTensorSpec" + "cannot be casted from src device mesh to target one" + ) + + if fake: + async_op = True # avoid invoking mesh_wait in FakeTensorMode. + # shortcut: return local tensor if no placement changes. + if current_spec.placements == target_spec.placements: + return local_tensors + + device_mesh = current_spec.mesh + + new_local_tensors = None + + current_placements = current_spec.placements + target_placements = target_spec.placements + # sorted_placements = list(enumerate(zip(current_placements, target_placements))) + # TODO: the order of commnunication matters, we need find a correct order when more + # than one communication happens between a single DTenosrSpec changes. + + looped_mesh_dims = range(device_mesh.ndim) + if reverse: + looped_mesh_dims = reversed(looped_mesh_dims) + for i in looped_mesh_dims: + current = current_placements[i] + target = target_placements[i] + if current == target: + # short cut: just use the original local tensor + new_local_tensors = local_tensors + continue + + redistribute_fn = get_redistribute_fn(current_spec, target_spec, current, target) + if not fake: + if isinstance(redistribute_fn, (R2R, R2S)): + new_local_tensors = redistribute_fn(local_tensors, current, target, device_mesh, i, emit_comm=emit_comm) + else: + new_local_tensors = redistribute_fn(local_tensors, current, target, device_mesh, i) + else: + if isinstance(redistribute_fn, (R2R, R2S)): + new_local_tensors = redistribute_fn.__fake_call__( + local_tensors, current, target, device_mesh, i, emit_comm=emit_comm + ) + else: + new_local_tensors = redistribute_fn.__fake_call__(local_tensors, current, target, device_mesh, i) + + assert new_local_tensors is not None + local_tensors = new_local_tensors + assert new_local_tensors is not None, "redistribute failed!" + return new_local_tensors + + +######################## DTensor collective ######################### + + +class DTensorRedistribute(torch.autograd.Function): + @classmethod + def apply(cls, *args, **kwargs): + # rewrite torch.autograd.Function.apply to skip functorch check, which is unnecessary for this autograder + return super(_SingleLevelFunction, cls).apply(*args, **kwargs) # type: ignore[misc] + + @staticmethod + def forward( + ctx, + inputs: List["dtensor.DTensor"], + device_mesh: DeviceMesh, + placements: Tuple[Placement], + async_op: bool = True, + ): + # FIXME: We use early return (it is now moved to `redistribute_local_tensor()`) to + # avoid view(). There are several hidden dangers here: + # - The change of the tensor wrapper may cause the failure of the tensor's hooks. + # - Modifying the tensor may change the result of is_param of parameters. + # - Dynamically modifying the computation graph may cause problems with autograd. + + previous_spec = inputs[0]._spec + target_spec = DTensorSpec(device_mesh, placements, tensor_meta=previous_spec.tensor_meta) + + ctx.previous_spec = previous_spec + ctx.async_op = async_op + + local_tensors = [input._local_tensor for input in inputs] + outputs = redistribute_local_tensor(local_tensors, previous_spec, target_spec, async_op, reverse=True) + for i, input in enumerate(inputs): + outputs[i].requires_grad_(input.requires_grad) + + # TODO: unify these + # from vescale.plan.hooks.factory_hooks import FactoryDispatchModeOff as NewFactoryDispatchModeOff + from vescale.dmodule._factory import FactoryDispatchModeOff as OldFactoryDispatchModeOff + + with ( + FakeTensorMode(allow_non_fake_inputs=True) + and torch.no_grad() + # unset factory mode. + # and NewFactoryDispatchModeOff() + and OldFactoryDispatchModeOff() + ): + fake_inputs = [torch.empty_strided(input.shape, input.stride(), dtype=input.dtype) for input in inputs] + fake_outs = redistribute_local_tensor( + fake_inputs, previous_spec, target_spec, async_op, reverse=True, fake=True + ) + target_spec.tensor_meta = TensorMeta( + shape=target_spec.tensor_meta.shape, + stride=fake_outs[0].stride(), + dtype=target_spec.tensor_meta.dtype, + ) + result_list = [] + for input, output in zip(inputs, outputs): + result_list.append( + dtensor.DTensor( + output, + target_spec.mesh, + target_spec.placements, + shape=target_spec.tensor_meta.shape, + dtype=target_spec.tensor_meta.dtype, + requires_grad=input.requires_grad, + stride=target_spec.tensor_meta.stride, + ) + ) + + return result_list + + +def distribute_tensor( + tensors: List[torch.Tensor], + device_mesh: Optional[DeviceMesh] = None, + placements: Optional[Sequence[Placement]] = None, +) -> List["dtensor.DTensor"]: + """ + Distribute a list of global `torch.Tensor` to the `device_mesh` according to the `placements` + specified. The rank of `device_mesh` and `placements` must be the same. + + Args: + tensors (List[torch.Tensor]): global torch.Tensor to be distributed. Note that if you + want to shard a tensor on a dimension that is not evenly divisible by + the number of devices in that mesh dimension, we use `torch.chunk` + semantic to shard the tensor and scatter the shards. + device_mesh (:class:`DeviceMesh`, optional): emulator DeviceMesh to distribute the + tensor, if not specified, must be called under a DeviceMesh context + manager, default: None + placements (List[:class:`Placement`], optional): the placements that + describes how to place the tensor on DeviceMesh, must have the same + number of elements as `device_mesh.ndim`. If not specified, we will + by default replicate the tensor across the `device_mesh` from the + first rank of each dimension of the `device_mesh`. + + Returns: + A list of :class:`DTensor` object + + Best practice to save memory: + >>> dist_tensor = distribute_tensor(global_tensor, ...) + >>> del global_tensor + """ + + torch._C._log_api_usage_once("torch.dtensor.distribute_tensor") + + # get default device mesh if there's nothing specified + device_mesh = device_mesh or mesh_resources.get_current_mesh() + device_type = device_mesh.device_type + + for i, tensor in enumerate(tensors): + if not tensor.is_leaf: + raise RuntimeError( + "`distribute_tensor` should be used to distribute leaf tensors! but found non-leaf tensor!" + ) + + # convert tensor to the corresponding device type if it's not in that device type + if device_type != tensor.device.type and not tensor.is_meta: + tensors[i] = tensor.to(device_type) + + # validate placements + placements: Tuple[Placement] = normalize_placements( + placements, device_mesh.ndim, tensor_ndim=tensor.ndim, none_as_replicate=True + ) + + # validate tensor type + results = [] + for tensor in tensors: + if isinstance(tensor, dtensor.DTensor): + # if the tensor is already a DTensor, we just need to check if the + # device mesh and placements are the same + if tensor.device_mesh != device_mesh: + raise ValueError( + f"Cannot distribute a DTensor with device mesh {tensor.device_mesh} " + f"to a different device mesh {device_mesh}." + ) + if tensor.placements != placements: + raise ValueError( + f"Cannot distribute a DTensor with placements {tensor.placements} " + f"to a different placements {placements}. do you want to call " + f"`redistribute` instead?" + ) + results.append(tensor) + if len(results) == len(tensors): + return results + + target_spec = DTensorSpec(mesh=device_mesh, placements=placements, tensor_meta=None) + + placements: Tuple[Placement] = tuple([Replicate()] * device_mesh.ndim) + tensor_meta = TensorMeta(shape=tensors[0].shape, stride=tensors[0].stride(), dtype=tensors[0].dtype) + current_spec = DTensorSpec(mesh=device_mesh, placements=placements, tensor_meta=tensor_meta) + local_tensors = redistribute_local_tensor( + local_tensors=tensors, + current_spec=current_spec, + target_spec=target_spec, + async_op=True, + emit_comm=True, + ) + + result_list = [] + for tensor, local_tensor in zip(tensors, local_tensors): + tensor_meta = TensorMeta(shape=tensor.size(), dtype=tensor.dtype, stride=tensor.stride()) + target_spec.tensor_meta = tensor_meta + + assert local_tensor is not None, "distributing a tensor should not be None" + # detach the local tensor passed to DTensor since after the construction + # of DTensor, autograd would work on top of DTensor instead of local tensor + result_list.append( + DTensor( + local_tensor.detach().requires_grad_(tensor.requires_grad), + target_spec.mesh, + target_spec.placements, + shape=target_spec.tensor_meta.shape, + dtype=target_spec.tensor_meta.dtype, + requires_grad=tensor.requires_grad, + stride=target_spec.tensor_meta.stride, + ) + ) + return result_list + + +def redistribute_dtensor( + dtensors: List["dtensor.DTensor"], + device_mesh: Optional[DeviceMesh] = None, + placements: Optional[Sequence[Placement]] = None, + async_op: bool = True, +) -> List["dtensor.DTensor"]: + """ + `redistribute_dtensor` performs necessary emulator collective operations that redistribute the current + DTensor from its current placements to a new placements, or from is current DeviceMesh + to a new DeviceMesh. i.e. we can turn a Sharded DTensor to a Replicated DTensor by + specifying a Replicate placement for each dimension of the DeviceMesh. + + Args: + device_mesh (:class:`DeviceMesh`, optional): emulator DeviceMesh to place the + DTensor, if not specified, must be called under a DeviceMesh + context manager, default: None + placements (List[:class:`Placement`], optional): the new placements that + describes how to place the DTensor into the DeviceMesh, must + have the same number of elements as `device_mesh.ndim`. + async_op (bool, optional): whether this redistribute is asynchronous in communication (for both forward and backward). + - True: the default asynchronous behavior for performance + - False: mostly used for third-party plugin op that doesn't accept asynchronous collective tensor. + + Returns: + A list of :class:`DTensor` object + + .. Note:: + - `redistribute_dtensor` is differentiable (i.e., redistribute happen for both forward and backward) TODO: backward compatibility + - This redistribute API currently only supports out of place redistribution, i.e. it always create a new DTensor object and leave the original one unchanged. + """ + return DTensorRedistribute.apply( + dtensors, + device_mesh, + normalize_placements(placements, mesh_ndim=device_mesh.ndim, tensor_ndim=dtensors[0].ndim), + async_op, + ) diff --git a/vescale/emulator/comm_primitive.py b/vescale/emulator/comm_primitive.py new file mode 100644 index 0000000..22a0503 --- /dev/null +++ b/vescale/emulator/comm_primitive.py @@ -0,0 +1,359 @@ +################################################################################ +# Copyright (c) Meta Platforms, Inc. and affiliates +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. +################################################################################ +# Modification Copyright 2023 ByteDance Ltd. and/or its affiliates. +################################################################################ + +from typing import List +import torch +from vescale.dtensor.placement_types import DTensorSpec, Partial, Placement, Replicate, Shard + +from abc import abstractmethod, ABCMeta + +from vescale.emulator.device_mesh import DeviceMesh +from vescale.emulator.mesh_collectives import mesh_all_gather, mesh_all_reduce, mesh_broadcast, mesh_scatter +from vescale.emulator.utils import torch_reduce_op_to_emulator + + +def _replicate_tensor(tensors: List[torch.Tensor], mesh: DeviceMesh, mesh_dim: int) -> torch.Tensor: + """ + Replicate (broadcast) a list of torch.Tensor on a mesh dimension (use + the first coordinate on the mesh dimension as source of truth) + """ + for i, tensor in enumerate(tensors): + tensors[i] = tensor.contiguous() + tensors = mesh_broadcast(tensors, mesh, mesh_dim=mesh_dim) + return tensors + + +def _reshard_to_replicate_with_pad_one_dim( + local_tensors: List[torch.Tensor], size_list: List[torch.Size], mesh: DeviceMesh, mesh_dim: int, shard_dim: int +) -> List[torch.Tensor]: + """ + This function all_gather all shards and return a list of tensors that + is replicated on the previously sharded mesh dimension + """ + num_chunks = mesh.size(dim=mesh_dim) + + tag_rank_list = mesh._dim_group_infos[mesh_dim] + dim_group = mesh.get_dim_groups()[mesh_dim] + + is_padded_list = [0 for _ in range(len(local_tensors))] + pad_sizes_list = [0 for _ in range(len(local_tensors))] + + for (tag, ranks), pg in zip(tag_rank_list, dim_group): + for i, rank in enumerate(ranks): + local_tensor = local_tensors[rank] + size = size_list[rank] + + # check if it needs to pad input tensor before all_gather + full_chunk_size = (size[shard_dim] + num_chunks - 1) // num_chunks + chunk_sizes = [ + max( + min(size[shard_dim], full_chunk_size * (idx + 1)) - full_chunk_size * idx, + 0, + ) + for idx in range(num_chunks) + ] + pad_sizes = [full_chunk_size - chunk_size for chunk_size in chunk_sizes] + is_padded = size[shard_dim] % num_chunks != 0 + + is_padded_list[rank] = is_padded + pad_sizes_list[rank] = pad_sizes + + pad_size = pad_sizes[i] + + if pad_size > 0: + local_tensor = _pad_tensor_on_shard_dim(local_tensor, pad_size, shard_dim) + local_tensors[rank] = local_tensor.contiguous() + + results = mesh_all_gather( + local_tensors, + mesh, + shard_dim, + mesh_dim, + ) + # Unpad the tensor if the input tensor was padded + for (tag, ranks), pg in zip(tag_rank_list, dim_group): + for i, rank in enumerate(ranks): + is_padded = is_padded_list[rank] + if is_padded: + pad_sizes = pad_sizes_list[rank] + result = results[rank] + + full_pad_size = sum(pad_sizes) + result = _unpad_tensor_on_shard_dim(result, full_pad_size, shard_dim) + + return results + + +def _pad_tensor_on_shard_dim( + tensor: torch.Tensor, + pad_size: int, + shard_dim: int, +): + pad = [0, 0] * (tensor.ndim - shard_dim) + pad[-1] = pad_size + return torch.nn.functional.pad(tensor, pad) + + +def _unpad_tensor_on_shard_dim(tensor: torch.Tensor, pad_size: int, shard_dim: int): + # NOTE: torch.narrow doesn't change stride meta, add contiguous to make sure + # it doesn't fail if followed by view ops. + return tensor.narrow( + shard_dim, + start=0, + length=tensor.size(shard_dim) - pad_size, + ).contiguous() + + +def _scatter_tensor_by_shard( + tensors: List[torch.Tensor], mesh: DeviceMesh, mesh_dim: int, shard_spec: Shard +) -> torch.Tensor: + """ + shard and scatter a list of tensor on a mesh dimension (use coordinate + 0 on the mesh dimension as source of truth) + """ + scatter_list_list = [0 for _ in range(len(tensors))] + pad_sizes_list = [0 for _ in range(len(tensors))] + outputs = [0 for _ in range(len(tensors))] + + tag_rank_list = mesh._dim_group_infos[mesh_dim] + dim_group = mesh.get_dim_groups()[mesh_dim] + + for (tag, ranks), pg in zip(tag_rank_list, dim_group): + for i, rank in enumerate(ranks): + tensor = tensors[rank] + scatter_list, pad_sizes = shard_spec._split_tensor( + tensor, num_chunks=mesh.size(dim=mesh_dim), with_padding=True, contiguous=True + ) + output = torch.empty_like(scatter_list[i]) + scatter_list_list[rank] = scatter_list + pad_sizes_list[rank] = pad_sizes + outputs[rank] = output + mesh_scatter(outputs, scatter_list_list, mesh, mesh_dim=mesh_dim) + + # Only unpad if the local_tensor was padded on the dimension. + for (tag, ranks), pg in zip(tag_rank_list, dim_group): + for i, rank in enumerate(ranks): + pad_sizes = pad_sizes_list[rank] + pad_size = pad_sizes[i] + if pad_size > 0: + output = outputs[rank] + outputs[rank] = shard_spec._unpad_tensor(output, pad_size) + return outputs + + +class BaseRedistributeFunc(metaclass=ABCMeta): + def __init__(self, global_shape: torch.Size = None): + self.global_shape = global_shape + + def get_or_compute_global_shape( + self, local_tensors, shard_placement: Shard, mesh: DeviceMesh, shard_mesh_dim: int + ) -> List[torch.Size]: + if self.global_shape is not None: + return [self.global_shape for _ in local_tensors] + # get the shape from annotation is another option + from vescale.plan.hooks.annotate_hook import ANNOT_NAME + + annot_shape_list = [] + for local_tensor in local_tensors: + annot_spec = getattr(local_tensor, ANNOT_NAME, None) + if (annot_spec is not None) and (annot_spec.shape is not None): + annot_shape_list.append(annot_spec.shape) + else: + break + if len(annot_shape_list) == len(local_tensors): + return annot_shape_list + + tag_rank_list = mesh._dim_group_infos[shard_mesh_dim] + dim_group = mesh.get_dim_groups()[shard_mesh_dim] + + global_shape_list = [0 for _ in range(len(local_tensors))] + for (tag, ranks), pg in zip(tag_rank_list, dim_group): + first = ranks[0] + global_shape = list(first.shape) + for rank in ranks[1:]: + local_tensor = local_tensors[rank] + shape = list(local_tensor.shape) + global_shape[shard_placement.dim] += shape[shard_placement.dim] + + global_shape = torch.Size(global_shape) + + for rank in ranks: + global_shape_list[rank] = global_shape + return global_shape_list + + @abstractmethod + def name(self): ... + + @abstractmethod + def __call__( + self, + tensors: List[torch.Tensor], + current_placement: Placement, + target_placement: Placement, + mesh: DeviceMesh, + mesh_dim: int = 0, + ) -> List[torch.Tensor]: ... + + @torch.no_grad() + def __fake_call__( + self, + global_tensors: List[torch.Tensor], + current_placement: Placement, + target_placement: Placement, + mesh: DeviceMesh, + mesh_dim: int = 0, + ): + # by default, we return a contiguous output. + return [global_tensor.new_empty(global_tensor.shape) for global_tensor in global_tensors] + + +class R2R(BaseRedistributeFunc): + def name(self): + return "R->R" + + @torch.no_grad() + def __call__( + self, + tensors: List[torch.Tensor], + current_placement: Replicate, + target_placement: Replicate, + mesh: DeviceMesh, + mesh_dim: int = 0, + emit_comm: bool = False, + ): + if not emit_comm: + return tensors + return _replicate_tensor(tensors=tensors, mesh=mesh, mesh_dim=mesh_dim) + + @torch.no_grad() + def __fake_call__( + self, + global_tensors: List[torch.Tensor], + current_placement: Placement, + target_placement: Placement, + mesh: DeviceMesh, + mesh_dim: int = 0, + emit_comm: bool = False, + ): + if not emit_comm: + return global_tensors + # contiguous output + return [global_tensor.new_empty(global_tensor.shape) for global_tensor in global_tensors] + + +class R2S(BaseRedistributeFunc): + def name(self): + return "R->S" + + @torch.no_grad() + def __call__( + self, + tensors: List[torch.Tensor], + current_placement: Replicate, + target_placement: Shard, + mesh: DeviceMesh, + mesh_dim: int = 0, + emit_comm: bool = False, + ): + if emit_comm: + return _scatter_tensor_by_shard(tensors=tensors, mesh=mesh, mesh_dim=mesh_dim, shard_spec=target_placement) + return _scatter_tensor_by_shard(tensors=tensors, mesh=mesh, mesh_dim=mesh_dim, shard_spec=target_placement) + + @torch.no_grad() + def __fake_call__( + self, + global_tensors: List[torch.Tensor], + current_placement: Placement, + target_placement: Placement, + mesh: DeviceMesh, + mesh_dim: int = 0, + emit_comm: bool = False, + ): + if not emit_comm: + return global_tensors + # contiguous output + return [global_tensor.new_empty(global_tensor.shape) for global_tensor in global_tensors] + + +class P2R(BaseRedistributeFunc): + def name(self): + return "P->R" + + @torch.no_grad() + def __call__( + self, + tensors: List[torch.Tensor], + current_placement: Partial, + target_placement: Replicate, + mesh: DeviceMesh, + mesh_dim: int = 0, + ): + reduce_op = torch_reduce_op_to_emulator(current_placement.reduce_op) + result = mesh_all_reduce( + tensors=tensors, + mesh=mesh, + reduce_op=reduce_op, + mesh_dim=mesh_dim, + ) + return result + + +class S2R(BaseRedistributeFunc): + def name(self): + return "S->R" + + @torch.no_grad() + def __call__( + self, + local_tensors: List[torch.Tensor], + current_placement: Shard, + target_placement: Replicate, + mesh: DeviceMesh, + mesh_dim: int = 0, + ): + def normalize_dim_for_shard(placement, tensor): + if placement.dim >= 0: + return placement + tensor_ndim = tensor.ndim if isinstance(tensor, torch.Tensor) else tensor + if tensor_ndim == 0: + return placement + new_dim = placement.dim + tensor_ndim + return Shard(new_dim) + current_placement = normalize_dim_for_shard(current_placement, tensor=local_tensors[0]) + return _reshard_to_replicate_with_pad_one_dim( + local_tensors, + self.get_or_compute_global_shape(local_tensors, current_placement, mesh, mesh_dim), + mesh, + mesh_dim, + current_placement.dim, + ) + + +def get_redistribute_fn( + current_spec: DTensorSpec, + target_spec: DTensorSpec, + current_placement: Placement, + target_placement: Placement, +) -> BaseRedistributeFunc: + # P -> + if current_placement.is_partial(): + if target_placement.is_replicate(): + return P2R() + # S -> + elif current_placement.is_shard(): + if target_placement.is_replicate(): + return S2R(global_shape=current_spec.shape if current_spec.tensor_meta is not None else None) + # R -> + elif current_placement.is_replicate(): + if target_placement.is_replicate(): + return R2R() + elif target_placement.is_shard(): + return R2S() + raise RuntimeError(f"redistribute from {current_placement} to {target_placement} not supported yet") diff --git a/vescale/emulator/device_mesh.py b/vescale/emulator/device_mesh.py new file mode 100644 index 0000000..3fee635 --- /dev/null +++ b/vescale/emulator/device_mesh.py @@ -0,0 +1,686 @@ +################################################################################ +# Copyright (c) Meta Platforms, Inc. and affiliates +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. +################################################################################ +# Modification Copyright 2023 ByteDance Ltd. and/or its affiliates. +################################################################################ + +import logging +import math +import warnings +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union + +import torch +from vescale.dtensor.device_mesh import DeviceMesh as vescaleDeviceMesh +from vescale.emulator.distributed import ( + ProcessGroup, + _find_pg_by_ranks_and_tag, + _get_default_group, + _get_group_size, + _get_group_tag, + delete_nccl_graph_for_pg, + get_process_group_ranks, + get_rank, + get_world_size, + init_process_group, + is_initialized, + new_group, + dump_nccl_graph_for_pg, +) + +from vescale.debug import DebugLogger + +logger = logging.getLogger(__name__) + +# only import numpy typing when type checking +if TYPE_CHECKING: + try: + from numpy.typing import ArrayLike + except ImportError: + logger.warning("DeviceMesh requires numpy >= 1.21 to be installed for type checking") + + +class _MeshEnv: + def __init__(self) -> None: + self.mesh_stack: List[DeviceMesh] = [] + self.child_to_parent_mapping: Dict[DeviceMesh, DeviceMesh] = {} + + def get_current_mesh(self) -> "DeviceMesh": + if len(self.mesh_stack) == 0: + raise RuntimeError("No device mesh is currently active!") + return self.mesh_stack[-1] + + def create_child_mesh(self, device_mesh: "DeviceMesh", mesh_dim: int, mesh_dim_name: str) -> "DeviceMesh": + # swap the current dim to the last dim then reshape to flatten out other + # dims, so we can just extract the list of ranks which contains cur_rank. + cur_rank = device_mesh.get_rank() + pg_ranks_by_dim = device_mesh.mesh.swapdims(-1, mesh_dim).reshape(-1, device_mesh.mesh.size(mesh_dim)) + + for mesh_1d in pg_ranks_by_dim: + sub_mesh = DeviceMesh( + device_mesh.device_type, + mesh_1d, + mesh_dim_names=(mesh_dim_name,), + _init_process_groups=False, + ) + if cur_rank in mesh_1d: + res_sub_mesh = sub_mesh + + res_sub_mesh._dim_group_infos = [device_mesh._dim_group_infos[mesh_dim]] + # Assign the current DeviceMesh as the parent of the child DeviceMesh. + self.child_to_parent_mapping[res_sub_mesh] = device_mesh + return res_sub_mesh + + def create_submesh_along_multi_dims( + self, device_mesh: "DeviceMesh", mesh_dims: List[int], cur_rank: int = None + ) -> "DeviceMesh": + # swap the current dim to the last dim then reshape to flatten out other + # dims, so we can just extract the list of ranks which contains cur_rank. + # check dims + dim_size = [-1] + for dim in mesh_dims: + if dim >= device_mesh.ndim: + raise RuntimeError("Mesh dim in sub groups out of range!") + dim_size.append(device_mesh.mesh.size(dim)) + mesh_tensor = device_mesh.mesh + for dim in mesh_dims: + mesh_tensor = mesh_tensor.swapdims(-1, dim) + if cur_rank is None: + cur_rank = device_mesh.get_rank() + pg_ranks_by_dims = mesh_tensor.reshape(dim_size) + for mesh_nd in pg_ranks_by_dims: + sub_mesh = DeviceMesh( + device_mesh.device_type, + mesh_nd, + _init_process_groups=False, + ) + if cur_rank in mesh_nd: + res_sub_mesh = sub_mesh + res_sub_mesh._dim_group_infos = [device_mesh._dim_group_infos[dim] for dim in mesh_dims] + self.child_to_parent_mapping[res_sub_mesh] = device_mesh + return res_sub_mesh + + def create_submesh_group(self, device_mesh: "DeviceMesh", mesh_dim: int) -> "DeviceMesh": + # swap the current dim to the last dim then reshape to flatten out other + # dims, so we can just extract the list of ranks which contains cur_rank. + # check dims + pg_ranks_by_dim = device_mesh.mesh.swapdims(-1, mesh_dim).reshape(-1, device_mesh.mesh.size(mesh_dim)) + res = [] + for mesh_1d in pg_ranks_by_dim: + sub_mesh = DeviceMesh( + device_mesh.device_type, + mesh_1d, + _init_process_groups=False, + ) + sub_mesh._dim_group_infos = [device_mesh._dim_group_infos[mesh_dim]] + # Assign the current DeviceMesh as the parent of the child DeviceMesh. + self.child_to_parent_mapping[sub_mesh] = device_mesh + res.append(sub_mesh) + return res + + def get_parent_mesh(self, device_mesh: "DeviceMesh") -> Optional["DeviceMesh"]: + return self.child_to_parent_mapping.get(device_mesh, None) + + def get_parent_mesh_dim(self, device_mesh: "DeviceMesh") -> Optional[int]: + """ + Return the index of the mesh dim in the parent mesh. + The device_mesh passed in needs to be sliced out from a parent mesh. + """ + parent_mesh = self.get_parent_mesh(device_mesh) + child_mesh_dim_names = device_mesh.mesh_dim_names + if parent_mesh and child_mesh_dim_names: + assert len(child_mesh_dim_names) == 1, "The child mesh can only be a 1D mesh." + child_mesh_dim_name = child_mesh_dim_names[0] + if parent_mesh.mesh_dim_names: + return parent_mesh.mesh_dim_names.index(child_mesh_dim_name) + return None + + @staticmethod + def num_devices_per_host(device_type: str) -> int: + return _get_device_handle(device_type).device_count() + + @staticmethod + def num_hosts(device_type: str) -> int: + # ProcessGroup can't tell us this info so we have to infer it, assume + # homogeneous hardware for now + return get_world_size() // _MeshEnv.num_devices_per_host(device_type) + + +mesh_resources: _MeshEnv = _MeshEnv() + + +def _get_device_handle(device_type: str = "cuda"): + """ + Get the module corresponding to the device_type which is cuda or cuda-like device. + For example, when the device_type is cuda, the module `torch.cuda` is returned. + Return None when there is no corresponding module for device_type, otherwise + return the corresponding module. + """ + return getattr(torch, device_type, None) + + +class DeviceMesh: + """ + DeviceMesh represents a mesh of devices (given by `device_type`), where layout + of devices could be represented as a n-d dimension array `mesh`, and each value + of the `mesh` is the global rank in the default process group. + + DeviceMesh could be used to describe the layout of devices across the cluster + via `mesh_dim_names`, and serves as a proxy for communication among the device lists + within the cluster. + + By default (`pg` is `None`), we use the default emulator ProcessGroup in this DeviceMesh class + to implement proper communications. Note that we also add collective wrappers in this + class. This is used to decouple detailed communication backend with the underlying + DTensor implementation. + + By giving an existing emulator ProcessGroup `pg`, we construct a device mesh from this `pg`, + instead of the default ProcessGroup. + + Here are the expected behaviors: + | `mesh` | `pg` | result | catch + --------------------------------------------------------------------------------------------- + | None | None | raise error! | + | EXIST | None | use `mesh` + default ProcessGroup | + | None | EXIST | use `pg`'s ranks + `pg` ProcessGroup | 1D mesh only + | EXIST | EXIST | use `pg`'s ranks + `pg` ProcessGroup | `mesh` must equal to `pg`'s ranks + + Args: + device_type (str): device type of the mesh. Currently supports: cpu, cuda/cuda-like, meta. + mesh (ndarray): could be a multi-dimension array or an integer tensor that + describes the layout of devices, the ids are global ids of the default process group. + mesh_dim_names (Optional[Tuple[str]]): A tuple of mesh dim names to be assigned to each + dimension of the multi-dimensional array that describes the layout of devices. Its + length must match the length of `mesh_shape`. Each string in mesh_dim_names must be unique. + pg (Optional[ProcessGroup]): the given emulator ProcessGroup. See above for expected behaviors. + + Returns: + A :class:`DeviceMesh` object + + Example (2 host with 4 GPUs each): + ``` + # The following program runs on each process/rank in SPMD manner. + # initialize device mesh as (2, 4) to represent the topology + # of cross-host(dim 0), and within-host (dim 1) + mesh = DeviceMesh(device_type="cuda", + mesh=[ + [0, 1, 2, 3], + [4, 5, 6, 7] + ]) + ``` + A reduction over the first dimension of mesh will reduce across + columns (0, 4), .. and (3, 7), a reduction over the second dimension + of mesh reduces across rows (0, 1, 2, 3) and (4, 5, 6, 7) + + Note: + DeviceMesh can be used as a context manager. + """ + + device_type: str + mesh: Optional[Union[torch.Tensor, "ArrayLike"]] + mesh_dim_names: Optional[Tuple[str, ...]] + + def __init__( + self, + device_type: str, + mesh: Optional[Union[torch.Tensor, "ArrayLike"]] = None, + *, + mesh_dim_names: Optional[Tuple[str, ...]] = None, + pg: Optional[ProcessGroup] = None, + _validate_mesh: bool = False, + _init_process_groups: bool = True, + ) -> None: + # for performance, update debug env once here + DebugLogger.update_vescale_debug_mode_from_env() + # check args + if mesh is None and pg is None: + raise ValueError("Either `mesh` or `pg` must be provided!") + if mesh is not None and pg is not None: + pg_mesh_tensor = torch.tensor(get_process_group_ranks(pg), dtype=torch.int, device="cpu") + mesh_tensor = ( + mesh.detach().cpu() + if isinstance(mesh, torch.Tensor) + else torch.tensor(mesh, dtype=torch.int, device="cpu") + ) + if not torch.equal(mesh_tensor, pg_mesh_tensor): + raise ValueError(f"mesh({mesh_tensor}) and pg({pg_mesh_tensor}) must have the same content!") + if pg is not None: + self.mesh = torch.tensor(get_process_group_ranks(pg), dtype=torch.int, device="cpu") + warnings.warn("Construction from given ProcessGroup is only supported for 1D mesh currently.") + # TO FIX: use `mesh` to reshape `pg_mesh_tensor` for nD mesh tensor + if mesh is not None: + self.mesh = ( + mesh.detach().cpu() + if isinstance(mesh, torch.Tensor) + else torch.tensor(mesh, dtype=torch.int, device="cpu") + ) + + self.device_type = device_type + self.mesh_dim_names = mesh_dim_names + if device_type == "cuda": + self.backend = "nccl" + else: + raise ValueError(f"Unsupported device_type: {device_type}") + + # private field to pre-generate DeviceMesh's hash + self._flatten_mesh_list = tuple(self.mesh.flatten().tolist()) + self._hash = hash((self._flatten_mesh_list, self.mesh.shape)) + + # step 1: try to create default world pg. + if pg is None: + pg = self._get_or_create_default_group() + else: + # TODO: this logic only applies when device_type is cuda + pg_world_size = get_world_size(group=pg) + device_handle = _get_device_handle(self.device_type) + num_devices_per_host = device_handle.device_count() + if pg_world_size > num_devices_per_host and pg_world_size % num_devices_per_host != 0: + raise RuntimeError( + f"DeviceMesh only support homogeneous hardware, but found " + f"{pg_world_size} ranks and {num_devices_per_host} {self.device_type} devices!" + ) + if self.device_type == "cuda": + + def _get_current_device(): + try: + if torch.cuda.is_available(): + return torch.cuda.current_device() + else: + return None + except AssertionError as e: + return None + + device_handle = _get_device_handle(self.device_type) + num_devices_per_host = device_handle.device_count() + local_rank = get_rank() % num_devices_per_host + if local_rank != _get_current_device(): + warnings.warn("Remember to set cuda device id to local rank!!!") + device_handle = _get_device_handle(self.device_type) + device_handle.set_device(local_rank) + + # step 2: validate the mesh before following usage. + if _validate_mesh: + self._validate_mesh(pg) + + # step 3: get coordinate of current global rank on the mesh. + # The world pg is used for device mesh identity (rank) on each + # process (we need to know if the current global rank is in the mesh or not) + rank_coords = (self.mesh == get_rank()).nonzero() + assert rank_coords.size(0) in (0, 1) + self._coordinate_on_dim: Optional[List[int]] = rank_coords[0].tolist() if rank_coords.size(0) > 0 else None + + # step 4: init multi subprocess group for the mesh object. + if _init_process_groups: + self._init_process_groups(pg) + + def _get_or_create_default_group(self): + default_initialized = is_initialized() + if not default_initialized: + init_process_group() + + world_size = get_world_size() + if self.mesh.numel() > world_size: + raise RuntimeError( + f"Mesh should not be bigger than default world size, but found {self.mesh.numel()} ranks!" + ) + + device_handle = _get_device_handle(self.device_type) + # TODO: if user want to pass pg_options, offer a way to do it + if not default_initialized and device_handle: + # automatically set the current cuda/cuda-like device base on num of gpu devices available in each host + # NOTE: This device selection would only work for homogeneous hardware. + num_devices_per_host = device_handle.device_count() + if world_size > num_devices_per_host and world_size % num_devices_per_host != 0: + raise RuntimeError( + f"DeviceMesh only support homogeneous hardware, but found " + f"{world_size} ranks and {num_devices_per_host} {self.device_type} devices!" + ) + device_handle.set_device(get_rank() % num_devices_per_host) + + return _get_default_group() + + def _validate_mesh(self, pg: ProcessGroup): + pass + + def _init_process_groups(self, pg: ProcessGroup): + # group tag/ranks associated with each mesh dimension, each mesh dimension should + # have one sub-group per rank + dim_group_infos: List[Tuple[Tuple[str, List[int]]]] = [] + + if self.mesh.ndim == 1 and self.mesh.numel() == _get_group_size(pg): + # if the mesh is the same as the given group, we just append the given + # pg to the first dim groups. + dim_group_infos.append([(_get_group_tag(pg), get_process_group_ranks(pg))]) + else: + # create sub pgs base on the mesh argument specified + for dim in range(self.mesh.ndim): + # swap the current dim to the last dim + # then reshape to flatten out other dims + pg_ranks_by_dim = self.mesh.swapdims(-1, dim).reshape(-1, self.mesh.size(dim)) + # multi-dim mesh, create subgroups by looping over the pg_ranks + # for each dim and append the groups + dim_group_infos_by_dim: List[Tuple[str, List[int]]] = [] + for dim_mesh in pg_ranks_by_dim: + subgroup_ranks = dim_mesh.tolist() + # call new_group regardless of the current rank in the + # pg or not, it's required that all ranks participate + # in subgroup construction + dim_group = new_group(ranks=subgroup_ranks, backend=self.backend) + dim_group_infos_by_dim.append((_get_group_tag(dim_group), subgroup_ranks)) + dim_group_infos.append(tuple(dim_group_infos_by_dim)) + self._dim_group_infos = dim_group_infos + + def __enter__(self) -> "DeviceMesh": + # set this mesh as the current mesh in mesh env + mesh_resources.mesh_stack.append(self) + return self + + # pyre-fixme[2]: Parameter must be annotated. + def __exit__(self, exc_type, exc_value, exc_traceback) -> None: + # pop this mesh from mesh env + mesh_resources.mesh_stack.pop() + + def __repr__(self) -> str: + return f"DeviceMesh:({self.mesh.tolist()})" + + def __hash__(self): + # ideally, we should use object id as hash, because different device mesh objects + # give different subprocess group, so different device meshes. + # in practice of sharding propagation, + # we only care about different mesh tensor (value, shape). + return self._hash + + def __eq__(self, other: object) -> bool: + if not isinstance(other, DeviceMesh): + return False + if id(self.mesh) == id(other.mesh): # short-cut eq + return True + if self.device_type != other.device_type: + return False + return self.mesh.shape == other.mesh.shape and self._flatten_mesh_list == other._flatten_mesh_list + + def __getitem__(self, mesh_dim_name: str) -> "DeviceMesh": + """ + Slice the current DeviceMesh based on the mesh_dim_name given to create a child + DeviceMesh. + + Args: + mesh_dim_name (str): the name of the mesh dimension of the parent DeviceMesh + to create a child DeviceMesh for. + Returns: + A :class:`DeviceMesh` object + + Example (2 host with 4 GPUs each): + ``` + # Below is a DeviceMesh with mesh_shape of (2, 4) and mesh_dim_name of ("dp", "tp") + mesh = DeviceMesh(device_type="cuda", + mesh=[ + [0, 1, 2, 3], + [4, 5, 6, 7] + ], + mesh_dim_names=["dp", "tp"]) + ) + ``` + Calling mesh["tp"] on rank 0, 1, 2, 3 would return a 1D child DeviceMesh:([0, 1, 2, 3]). + Calling mesh["tp"] on rank 4, 5, 6, 7 would return a 1D child DeviceMesh:([4, 5, 6, 7]). + Calling mesh["dp"] on rank 0, 4 would return a 1D child DeviceMesh:([0, 4]). + Calling mesh["dp"] on rank 1, 5 would return a 1D child DeviceMesh:([1, 5]). + Calling mesh["dp"] on rank 2, 6 would return a 1D child DeviceMesh:([2, 6]). + Calling mesh["dp"] on rank 3, 7 would return a 1D child DeviceMesh:([3, 7]). + """ + if self.mesh.ndim <= 1: + raise RuntimeError(f"Cannot slice a DeviceMesh with {self.mesh.ndim} dimension.") + if self.mesh_dim_names is None: + raise KeyError( + "No `mesh_dim_names` found.", + "To slice the device mesh, please call `init_device_mesh` with `mesh_dim_names`.", + ) + if mesh_dim_name not in self.mesh_dim_names: + raise KeyError( + f"Mesh dimension '{mesh_dim_name}' does not exist.", + f"Available mesh dimensions are: {self.mesh_dim_names}", + ) + mesh_dim = self.mesh_dim_names.index(mesh_dim_name) + submesh = mesh_resources.create_child_mesh(self, mesh_dim, mesh_dim_name) + + return submesh + + def find_pg_by_tuple_of_ranks_and_tag( + self, dim_group_info_by_dim: Tuple[Tuple[str, List[int]]] + ) -> List[ProcessGroup]: + """ + Find the process group by the given tuple of ranks and tag. + + Args: + dim_group_info_by_dim (Tuple[str, List[int]]): the tuple of ranks and tag + for the given mesh dimension. + Returns: + A :class:`ProcessGroup` object + """ + pg_list = [] + for pg_tag, pg_ranks in dim_group_info_by_dim: + pg_list.append(_find_pg_by_ranks_and_tag(pg_tag, pg_ranks)) + return pg_list + + def get_dim_groups(self, mesh_dim: Optional[int] = None) -> Union[ProcessGroup, List[ProcessGroup]]: + if not hasattr(self, "_dim_group_infos"): + raise RuntimeError("DeviceMesh process groups not initialized!") + if mesh_dim is not None: + return self.find_pg_by_tuple_of_ranks_and_tag(self._dim_group_infos[mesh_dim]) + else: + dim_groups = [] + for mesh_dim in range(self.mesh.ndim): + dim_groups.append(self.find_pg_by_tuple_of_ranks_and_tag(self._dim_group_infos[mesh_dim])) + return dim_groups + + def size(self, dim: Optional[int] = None) -> int: + return self.mesh.numel() if dim is None else self.mesh.size(dim) + + @property + def ndim(self) -> int: + return self.mesh.ndim + + @property + def ndevice(self) -> int: + return torch.numel(self.mesh) + + @property + def shape(self) -> Tuple[int, ...]: + return tuple(self.mesh.shape) + + def get_rank(self) -> int: + return get_rank() + + def get_local_rank(self, mesh_dim: Optional[int] = None) -> int: + """ + Returns the local rank of the given mesh_dim of the DeviceMesh. + + Args: + mesh_dim (int, optional): it is the index of the mesh dimension. Default is None. + + Returns: + An integer denotes the local rank. + + The following program runs on each process/rank in an SPMD manner. In this example, we have 2 + hosts with 4 GPUs each. + Calling mesh_2d.get_local_rank(mesh_dim=0) on rank 0, 1, 2, 3 would return 0. + Calling mesh_2d.get_local_rank(mesh_dim=0) on rank 4, 5, 6, 7 would return 1. + Calling mesh_2d.get_local_rank(mesh_dim=1) on rank 0, 4 would return 0. + Calling mesh_2d.get_local_rank(mesh_dim=1) on rank 1, 5 would return 1. + Calling mesh_2d.get_local_rank(mesh_dim=1) on rank 2, 6 would return 2. + Calling mesh_2d.get_local_rank(mesh_dim=1) on rank 3, 7 would return 3. + """ + if self.ndim > 1 and mesh_dim is None: + raise RuntimeError( + f"Found the DeviceMesh have {self.mesh.ndim} dimensions", + "Optional kwarg `mesh_dim` needs to be specified when device_mesh.ndim > 1.", + ) + elif mesh_dim is None: + mesh_dim = 0 + + mesh_dim_group = self.get_dim_groups(mesh_dim) + assert isinstance(mesh_dim_group, ProcessGroup), "We expect ProcessGroup before calling `get_rank`!" + + return get_rank(mesh_dim_group) + + def get_coordinate(self) -> Optional[List[int]]: + """ + Return the relative indices of this rank relative to all + dimensions of the mesh. If this rank is not part of the mesh, return None. + """ + return self._coordinate_on_dim if self._coordinate_on_dim else None + + def enforce_cpu_mesh_tensor(self) -> None: + """ + move `mesh` tensor to cpu for deterministic device; + necessary for comparison and checkpoint loading. + """ + with torch.no_grad(): + self.mesh = self.mesh.cpu() + + def get_submesh(self, mesh_dims: Union[List[int], List[str]]) -> "DeviceMesh": + dims = [] + for dim in mesh_dims: + if isinstance(dim, int): + dims.append(dim) + elif isinstance(dim, str): + assert dim in self.mesh_dim_names, f"Mesh dimension '{dim}' does not exist." + dims.append(self.mesh_dim_names.index(dim)) + return mesh_resources.create_submesh_along_multi_dims(self, dims) + + def get_all_submesh(self, dim: int or str) -> List["DeviceMesh"]: + if isinstance(dim, str): + assert dim in self.mesh_dim_names, f"Mesh dimension '{dim}' does not exist." + mesh_dim = self.mesh_dim_names.index(dim) + else: + mesh_dim = dim + return mesh_resources.create_submesh_group(self, mesh_dim) + + def get_mapping_rank(self, other: "DeviceMesh"): + """ + for cross mesh resharding + we assume that the mesh is 1,2,4,8 + the size will have gcd value + """ + mesh_list = self.mesh.view(-1).tolist() + index = mesh_list.index(self.get_rank()) + other_mesh_list = other.mesh.view(-1).tolist() + gcd_value = math.gcd(len(mesh_list), len(other_mesh_list)) + if gcd_value == 1 and len(mesh_list) != 1 and len(other_mesh_list) != 1: + raise RuntimeError(f"mesh resharding the wrong shape of device mesh {mesh_list} vs {other_mesh_list}") + + a = len(mesh_list) + b = len(other_mesh_list) + factor = max(a, b) // min(a, b) + + if a > b: # group down + data = {} + for i in range((index // factor) * factor, factor): + data.update({mesh_list[index]: other_mesh_list[index // factor]}) + return data + elif a < b: # group up + return [other_mesh_list[i] for i in range(index * factor, (index + 1) * factor)] + else: + return other_mesh_list[index] + + +def init_device_mesh( + device_type: str, + mesh_shape: Tuple[int, ...], + *, + mesh_dim_names: Optional[Tuple[str, ...]] = None, +) -> DeviceMesh: + """ + Initializes an emulator `DeviceMesh` based on `device_type`, `mesh_shape`, and `mesh_dim_names` parameters. + This creates a DeviceMesh with a mesh layout of n-d dimensional array, n being the len(mesh_shape) + and ith dimension being in size mesh_shape[i]. If mesh_dim_names is provided, each dimension is + labeled as mesh_dim_names[i]. + + + Args: + device_type (str): device type of the mesh. Currently supports: cpu, cuda/cuda-like. + mesh_shape: Tuple[int]: A tuple describes the dimension of the multi-dimesnion array + that describes the layout of devices. + Kwargs: + mesh_dim_names: Optional[Tuple[str]]: A tuple of mesh dim names to be assigned to each dimension + of the multi-dimensional array that describes the layout of devices. Its length must match the length + of `mesh_shape`. Each string in mesh_dim_names must be unique. + + Returns: + A :class:`DeviceMesh` object + + .. note: If no process group is found, init_device_mesh will initialize distributed process group/groups + behind the scene, which are required for distributed communications. + + Example: + >>> # xdoctest: +SKIP + >>> from torch.distributed._tensor.device_mesh import init_device_mesh + >>> + >>> mesh_1d = init_device_mesh("cuda", mesh_shape=(8,)) + >>> mesh_2d = init_device_mesh("cuda", mesh_shape=(2, 8), mesh_dim_names=("dp", "tp")) + """ + if mesh_dim_names is not None: + if len(set(mesh_dim_names)) != len(mesh_dim_names): + raise RuntimeError( + "Each mesh_dim_name must be uqique.", + f"Found repeated mesh_dim_name in mesh_dim_names {mesh_dim_names}", + ) + + if len(mesh_shape) != len(mesh_dim_names): + raise RuntimeError( + "mesh_shape and mesh_dim_names should have same length!", + f"Found len(mesh_dim_names): {len(mesh_dim_names)} and len(mesh_shape):{len(mesh_shape)}.", + ) + + mesh = torch.arange(math.prod(mesh_shape)).view(mesh_shape) + device_mesh = DeviceMesh( + device_type=device_type, + mesh=mesh, + mesh_dim_names=mesh_dim_names, + ) + + return device_mesh + + +def dump_nccl_graph_for_mesh(emulator_mesh: DeviceMesh, vescale_mesh: vescaleDeviceMesh): + """ + Dump NCCL graph for a given mesh. + + Args: + emulator_mesh (DeviceMesh): The emulator mesh. + vescale_mesh (vescale.dtensor.device_mesh.DeviceMesh): The Vescale mesh. + """ + my_rank = vescale_mesh.get_rank() + + # dump all other process groups + # get all pgs in vescale_mesh + all_pgs_infos = vescale_mesh._dim_group_infos + # get all pgs in emulator_mesh + all_pgs_emulator_infos = emulator_mesh._dim_group_infos + # flatten 2D list all_pgs_infos into 1D list + all_pgs_emulator_infos = [item for sublist in all_pgs_emulator_infos for item in sublist] + my_rank = vescale_mesh.get_rank() + for tag, ranks in all_pgs_infos: + emulator_pg = _find_pg_by_ranks_and_tag(tag, ranks) + torch_pg = torch.distributed.distributed_c10d._find_pg_by_ranks_and_tag(tag, ranks) + # dump nccl graph for emulator_pg + dump_nccl_graph_for_pg(emulator_pg, torch_pg, my_rank) + + +def delete_nccl_graph_for_mesh(emulator_mesh: DeviceMesh): + """ + Delete NCCL graph for a given mesh. + + Args: + emulator_mesh (DeviceMesh): The emulator mesh. + """ + # get all pgs in emulator_mesh + all_pgs_emulator_infos = emulator_mesh._dim_group_infos + # flatten 2D list all_pgs_infos into 1D list + all_pgs_emulator_infos = [item for sublist in all_pgs_emulator_infos for item in sublist] + for tag, ranks in all_pgs_emulator_infos: + emulator_pg = _find_pg_by_ranks_and_tag(tag, ranks) + delete_nccl_graph_for_pg(emulator_pg) diff --git a/vescale/emulator/distributed.py b/vescale/emulator/distributed.py new file mode 100644 index 0000000..db9dc27 --- /dev/null +++ b/vescale/emulator/distributed.py @@ -0,0 +1,809 @@ +################################################################################ +# +# Copyright 2023 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ +# Some code comes from distributed_c10d.py in PyTorch +# Original license: +# Copyright (c) Meta Platforms, Inc. and affiliates +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. +################################################################################ + +import hashlib +import os +from typing import Dict, List, Optional +import torch +import torch.distributed +from torch.distributed.distributed_c10d import ProcessGroup as torchProcessGroup +from vescale.emulator.nccl.constants import NCCL_ALGO_RING, NCCL_ALGO_TREE, WARP_SIZE, NcclFunc +from vescale.emulator.reduce_kernel import ReduceOp +from vescale.emulator.all_reduce import run_ring_all_reduce, run_tree_all_reduce +from vescale.emulator.all_gather import run_ring_all_gather +from vescale.emulator.reduce_scatter import run_ring_reduce_scatter +from vescale.emulator.all_to_all import run_all_to_all + +from vescale.emulator.utils import flatten_tensors, restore_tensors +from vescale.emulator.nccl.include.graph import NCCL_TOPO_PATTERN_TREE +from vescale.emulator.calculate_chunk_size import get_info_nchannels_nthreads_proto + + +RANK = -1 + + +def set_rank(rank): + global RANK + RANK = rank + + +class ProcessGroup: + """ + A class for the emulator ProcessGroup. + """ + + def __init__(self, store, rank, size, backend="nccl"): + self._size = size + if backend == "nccl": + self._backend = backend + else: + raise NotImplementedError(f"backend {backend} is not supported") + + def rank(self): + global RANK + return RANK + + def size(self): + return self._size + + def _set_group_name(self, group_name): + self.group_name = group_name + + def get_nccl_graph_xml(self): + return get_nccl_graph_xml(self) + + def get_from_group(self): + ranks = _world.pg_group_ranks[self].keys() + if self._backend == "nccl": + device = torch.device("cuda") + else: + device = torch.device("cuda") + nnodes = 1 + return ranks, device, nnodes + + def all_reduce(self, tensors, op=ReduceOp.SUM, tree_structure=None): + """ + Reduces the tensor data across all tensors in the list in a way that all get the final result. + + After the call each tensor in the tensor list ``tensors`` is going to be bitwise identical. + + Args: + tensors (List[Tensor]): Input and output of the collective. The function + operates in-place. + op (optional): One of the values from + ``vescale.emulator.reduce_kernel.ReduceOp``. + Specifies an operation used for element-wise reductions. + tree_structure (optional): A list of lists of ranks. + The first list represents the nodes in the cluster, and the second list is the devices of the nodes. + + Returns: + None. + """ + ranks, device, nnodes = self.get_from_group() + flattenend_tensors, original_shapes = flatten_tensors(tensors) + info, nchannels, nthreads, protocol = get_info_nchannels_nthreads_proto( + self, + NcclFunc.ncclFuncAllReduce, + len(flattenend_tensors[0]), + flattenend_tensors[0].dtype, + len(ranks), + nnodes, + ) + nwarps = int(nthreads / WARP_SIZE) + algo = info.algorithm + if algo == NCCL_ALGO_RING: + results = run_ring_all_reduce( + info, + nchannels, + nwarps, + protocol, + flattenend_tensors, + ranks, + device, + flattenend_tensors[0].size()[0], + 0, + op, + ) + elif algo == NCCL_ALGO_TREE: + mode = NCCL_TOPO_PATTERN_TREE + results = run_tree_all_reduce( + info, + nchannels, + nwarps, + protocol, + flattenend_tensors, + tree_structure, + ranks, + _world.pg_group_ranks[self], + mode, + device, + flattenend_tensors[0].size()[0], + 0, + op, + ) + results = restore_tensors(results, original_shapes) + for i in range(len(tensors)): + tensors[i] = results[i] + + def all_gather(self, tensors_list, tensors, async_op=False): + """ + Gathers tensors in the list and return a list of list of tensors, + which represents the gathered result on each rank. + + Args: + tensors_list (List[List[Tensor]]): Output list. The length is equal + to group size and each element in the first list is the gathered + result (List[Tensor]) of the corresponding rank. + tensors (List[Tensor]): Tensor to be broadcast on each rank. + async_op (bool, optional): Whether this op should be an async op + + Returns: + None. + """ + ranks, device, nnodes = self.get_from_group() + tensors, original_shapes = flatten_tensors(tensors) + results = run_ring_all_gather( + tensors, ranks, device, max(tensors[0].size()[0] // len(ranks), 1), tensors[0].size()[0], 0 + ) + new_shape_list = [] + for shape in original_shapes: + new_shape = [] + new_shape.append(len(ranks)) + for s in shape: + new_shape.append(s) + new_shape_list.append(new_shape) + results = restore_tensors(results, new_shape_list) + for i in range(len(tensors_list)): + tensors_list[i] = [t.squeeze(0) for t in torch.split(results[i], 1)] + + def reduce_scatter(self, outputs, tensors_list, op=ReduceOp.SUM): + """ + Reduces, then scatters a list of list of tensors to all ranks in a group. + + Args: + outputs (List[Tensor]): Output list. The length is equal + to group size and each element in the first list is the scattered tensor + result (Tensor) of the corresponding rank. + tensors_list (List[List[Tensor]]): List of list of tensors. The first list + represents the data on each rank. The second list is the list of tensors + to reduce and scatter. + op (optional): One of the values from + ``vescale.emulator.reduce_kernel.ReduceOp``. + Specifies an operation used for element-wise reductions. + + Returns: + None. + + """ + ranks, device, nnodes = self.get_from_group() + tensors = [torch.stack(tensor_list) for tensor_list in tensors_list] + tensor_list, original_shapes = flatten_tensors(tensors) + info, nchannels, nthreads, protocol = get_info_nchannels_nthreads_proto( + self, NcclFunc.ncclFuncAllReduce, len(tensor_list[0]), tensor_list[0].dtype, len(ranks), nnodes + ) + nwarps = int(nthreads / WARP_SIZE) + results = run_ring_reduce_scatter( + info, + nchannels, + nwarps, + protocol, + tensor_list, + ranks, + device, + tensor_list[0].size()[0] // len(ranks), + tensor_list[0].size()[0] // len(ranks), + 0, + op, + ) + new_shape_list = [] + for shape in original_shapes: + new_shape = [] + for i, s in enumerate(shape): + if i == 0: + pass + else: + new_shape.append(s) + new_shape_list.append(new_shape) + results = restore_tensors(results, new_shape_list) + for i in range(len(outputs)): + outputs[i] = results[i] + + def all_to_all(self, outputs_list, tensors_list, datatype=torch.float32, async_op=False): + """ + Scatters list of input tensors to all processes in a group and return gathered list of tensors in output list. + + Args: + outputs_list (List[List[Tensor]]): List of list of tensors to be gathered. One list + per rank. + tensors_list (List[List[Tensor]]): List of list of tensors to scatter. The first list + represents the data on each rank. The second list is the list of tensors to scatter one per rank. + datatype (torch.dtype): Data type of the input and output tensors. + async_op (bool, optional): Whether this op should be an async op. + """ + ranks, device, nnodes = self.get_from_group() + tensors_list = [torch.stack(tensor_list) for tensor_list in tensors_list] + + tensor_list, original_shapes = flatten_tensors(tensors_list) + results = run_all_to_all( + tensor_list, + ranks, + device, + datatype, + tensor_list[0].size()[0] // len(ranks), + tensor_list[0].size()[0] // len(ranks), + 0, + ) + results = restore_tensors(results, original_shapes) + for i in range(len(outputs_list)): + for j in range(len(outputs_list[i])): + outputs_list[i][j] = results[i][j] + + +def get_world_size(group: Optional[ProcessGroup] = None) -> int: + """ + Return the number of processes in the current process group. + + Args: + group (ProcessGroup, optional): The process group to work on. If None, + the default process group will be used. + + Returns: + The world size of the process group\ + """ + return _get_group_size(group) + + +# DO NOT USE THESE FIELDS DIRECTLY. +# Use them through the _world object to make sure the _world override mechanism +_pg_names: Dict[ProcessGroup, str] = {} +_pg_group_ranks: Dict[ProcessGroup, Dict[int, int]] = {} # key: global ranks, value: group ranks +_group_count = 0 +_tags_to_pg: Dict[str, List[ProcessGroup]] = {} +_pg_to_tag: Dict[ProcessGroup, str] = {} +_pg_to_xml: Dict[ProcessGroup, str] = {} # key: ProcessGroup, value: xml file path + + +class _World: + """ + Container class for emulator process group state. + + This is used during registration and lookup of PG state. + """ + + def __init__(self): + self._default_pg = None + + @property + def default_pg(self): + """ + Process group that includes all ranks of the cluster. + + This default ProcessGroup is used by emulator APIs when a ProcessGroup is needed + but None is provided. + """ + return self._default_pg + + @default_pg.setter + def default_pg(self, value): + self._default_pg = value + + @property + def tags_to_pg(self) -> Dict[str, List[ProcessGroup]]: + global _tags_to_pg + return _tags_to_pg + + @property + def pg_to_tag(self) -> Dict[ProcessGroup, str]: + global _pg_to_tag + return _pg_to_tag + + @property + def pg_group_ranks(self) -> Dict[ProcessGroup, Dict[int, int]]: + """ + Process group's global rank to local rank mapping. + + TODO don't expose the map, expose fine grained ops + """ + global _pg_group_ranks + return _pg_group_ranks + + @property + def pg_names(self) -> Dict[ProcessGroup, str]: + """ + Process group's names, map from ProcessGroup to str. + + TODO don't expose the map, expose fine grained ops + """ + global _pg_names + return _pg_names + + @property + def group_count(self) -> int: + """ + Process group count for default naming. + + TODO don't expose group_count, use something else instead + """ + global _group_count + return _group_count + + @group_count.setter + def group_count(self, value): + """Use to compute the name of ProcessGroups when using global synchronization.""" + global _group_count + _group_count = value + + @property + def pg_to_xml(self) -> Dict[ProcessGroup, str]: + """ + Process group's nccl graph xml file path, map from ProcessGroup to str. + + TODO don't expose the pg_to_xml, use something else instead + """ + global _pg_to_xml + return _pg_to_xml + + +_world = _World() +"""Holds the singleton instance of ``_World`` used by emulator. Experimental extension point to override it""" + + +class _WorldMeta(type): + """ + Meta class of ``group`` and ``GroupMember``. + + Allows them to have the class property ``WORLD``. + """ + + @property + def WORLD(cls) -> Optional[ProcessGroup]: + return _world.default_pg + + @WORLD.setter + def WORLD(cls, pg: Optional[ProcessGroup]): + _world.default_pg = pg + + +class GroupMember(metaclass=_WorldMeta): + """Group member class.""" + + NON_GROUP_MEMBER = -100 + + +def _find_pg_by_ranks_and_tag(tag: str, ranks: List[int]) -> ProcessGroup: + if len(tag) > 0 and not tag.startswith("ptd:") and not tag.startswith("user:"): + tag = f"user:{tag}" + + for group in _world.tags_to_pg.get(tag, []): + if group.size() != len(ranks): + continue + + group_ranks = get_process_group_ranks(group) + good = all(r in group_ranks for r in ranks) + if good: + return group + return None + + +def is_initialized() -> bool: + """Check if the default process group has been initialized.""" + return GroupMember.WORLD is not None + + +def _get_default_group(): + """Get the default process group created by init_process_group.""" + if not is_initialized(): + raise ValueError( + "Default process group has not been initialized, " "please make sure to call init_process_group." + ) + return GroupMember.WORLD + + +def _get_group_size(group): + """Get a given group's world size.""" + if group is GroupMember.WORLD or group is None: + default_pg = _get_default_group() + return default_pg.size() + return group.size() + + +def _get_group_tag(pg: ProcessGroup) -> str: + """Return the tag associated with ``pg``.""" + tag = _world.pg_to_tag[pg] + if tag.startswith("user:"): + tag = tag[5:] + return tag + + +def get_process_group_ranks(group: ProcessGroup): + """ + Get all ranks associated with ``group``. + + Args: + group (ProcessGroup): ProcessGroup to get all ranks from. + + Returns: + List of global ranks ordered by group rank. + """ + return list(_world.pg_group_ranks[group].keys()) + + +def _rank_not_in_group(group: ProcessGroup): + """Check if the current process's rank is not in a given group.""" + if group is None: + return False + return group == GroupMember.NON_GROUP_MEMBER + + +def get_rank(group: Optional[ProcessGroup] = None) -> int: + """ + Return the rank of the current process in the provided ``group``, default otherwise. + + Rank is a unique identifier assigned to each process within a distributed + process group. They are always consecutive integers ranging from 0 to + ``world_size``. + + Args: + group (ProcessGroup, optional): The process group to work on. If None, + the default process group will be used. + + Returns: + The rank of the process group + -1, if not part of the group + + """ + if _rank_not_in_group(group): + return -1 + + default_pg = _get_default_group() + if group is None or group is GroupMember.WORLD: + return default_pg.rank() + + return get_group_rank(group, default_pg.rank()) + + +def get_group_rank(group: ProcessGroup, global_rank: int) -> int: + """ + Translate a global rank into a group rank. + + ``global_rank`` must be part of ``group`` otherwise this raises RuntimeError. + + Args: + group (ProcessGroup): ProcessGroup to find the relative rank. + global_rank (int): Global rank to query. + + Returns: + Group rank of ``global_rank`` relative to ``group`` + + N.B. calling this function on the default process group returns identity + """ + if group is GroupMember.WORLD: + return global_rank + if group not in _world.pg_group_ranks: + raise ValueError(f"Group {group} is not registered, please create group with torch.distributed.new_group API") + group_ranks = _world.pg_group_ranks[group] + if global_rank not in group_ranks: + raise ValueError(f"Global rank {global_rank} is not part of group {group}") + + return group_ranks[global_rank] + + +def new_group(ranks=None, timeout=None, backend=None, pg_options=None, use_local_synchronization=False): + """ + Create a new emulator process group. + + Args: + ranks (list[int]): List of ranks of group members. If ``None``, will be + set to all ranks. Default is ``None``. + timeout (timedelta, optional): see `init_process_group` for details and default value. + backend (str or Backend, optional): The backend to use. Should be set to None. + pg_options (ProcessGroupOptions, optional): process group options + specifying what additional options need to be passed in during + the construction of specific process groups. Should be set to None. + use_local_synchronization (bool, optional): perform a group-local + barrier at the end of the process group creation. Should be set to False + + Returns: + A handle of emulator process group that can be given to collective calls. + """ + return _new_group_with_tag( + ranks, timeout, backend, pg_options, None, use_local_synchronization=use_local_synchronization + ) + + +def _new_group_with_tag( + ranks=None, timeout=None, backend=None, pg_options=None, pg_tag=None, use_local_synchronization=False +): + """ + Variant of ``new_group`` that exposes tag creation. + """ + global _world + + default_pg = _get_default_group() + global_world_size = default_pg.size() + + # checks the input ranks + if ranks is not None: + ranks = sorted(ranks) + group_world_size = len(ranks) + if group_world_size > global_world_size: + raise ValueError( + "the new group's world size should be less or " "equal to the world size set by " "init_process_group" + ) + # check ranks' sanity + for rank in ranks: + if rank < 0 or rank >= global_world_size: + raise ValueError("The new group's rank should be within " "the world_size set by init_process_group") + else: + ranks = list(range(global_world_size)) + group_world_size = global_world_size + + group_name = _process_group_name(ranks, use_hashed_name=use_local_synchronization) + + pg, _ = _new_process_group_helper( + group_world_size, None, ranks, backend, None, group_name, pg_options=pg_options, timeout=timeout, pg_tag=pg_tag + ) + + # Create the global rank to group rank mapping + _world.pg_group_ranks[pg] = {global_rank: group_rank for group_rank, global_rank in enumerate(ranks)} + + return pg + + +def _new_process_group_helper( + group_size, + group_rank, + global_ranks_in_group, + backend, + store, + group_name, + pg_options=None, + timeout=None, + pg_tag=None, +): + """ + Create a new emulator process group. + + This function is called with ``global_ranks_in_group == []`` for the default group. + """ + global _world + + if group_name in _world.pg_names.values(): + raise ValueError("The specified group name has already been " "created, please use a different group name") + + if pg_tag not in [None, ""]: + # creating with the same tag and rank set results in the same underlying PG + existing_group = _find_pg_by_ranks_and_tag(pg_tag, global_ranks_in_group) + if existing_group: + return existing_group, None + + pg: ProcessGroup = ProcessGroup(None, group_rank, group_size, backend) + + # update global state + assert group_name is not None + _world.pg_names[pg] = group_name + pg._set_group_name(group_name) + + # "" is the default tag for user PGs + if pg_tag in [None, ""]: + pg_tag = f"ptd:{group_name}" + _world.tags_to_pg.setdefault("", []).append(pg) + else: + pg_tag = f"user:{pg_tag}" + + _world.tags_to_pg.setdefault(pg_tag, []).append(pg) + _world.pg_to_tag[pg] = pg_tag + return pg, None + + +# helper function for deterministically hashing a list of ranks +def _hash_ranks(ranks: List[int]): + return hashlib.sha1(bytes("_".join(map(str, ranks)), "utf-8")).hexdigest() + + +def _process_group_name(ranks, use_hashed_name): + global _world + if use_hashed_name: + pg_name = _hash_ranks(ranks) + while pg_name in _world.pg_names.values(): + pg_name = hashlib.sha1(bytes(pg_name + "_", "utf-8")).hexdigest() + else: + pg_name = str(_world.group_count) + _world.group_count += 1 + return pg_name + + +def _update_default_pg(pg): + _world.default_pg = pg + + +def init_process_group( + backend=None, + init_method=None, + timeout=None, + world_size: int = -1, + rank: int = -1, + store=None, + group_name: str = "", + pg_options=None, +): + """ + Initialize the default emulator process group. + + Args: + backend (str or Backend, optional): The backend to use. Should be set to None + init_method (str, optional): URL specifying how to initialize the + process group. Should be set to None. + world_size (int, optional): Number of processes participating in + the job. Required if ``store`` is specified. + rank (int, optional): Rank of the current process (it should be a + number between 0 and ``world_size``-1). + Required if ``store`` is specified. + store(Store, optional): Key/value store accessible to all workers, used + to exchange connection/address information. + Should be set to None. + timeout (timedelta, optional): Timeout for operations executed against + the process group. + + group_name (str, optional, deprecated): Group name. This argument is ignored + pg_options (ProcessGroupOptions, optional): process group options + specifying what additional options need to be passed in during + the construction of specific process groups. This argument is ignored + """ + global _world + + if GroupMember.WORLD is not None: + raise ValueError("trying to initialize the default process group twice!") + + assert (store is None) or (init_method is None), "Cannot specify both init_method and store." + + if store is not None: + assert world_size > 0, "world_size must be positive if using store" + assert rank >= 0, "rank must be non-negative if using store" + elif init_method is None: + init_method = "env://" + + """ + Group name is not visible to users unless they access + internals of c10d. This means we can ignore the value + they provide as it not exposed in a public way. + """ + group_name = _process_group_name([], use_hashed_name=False) + + default_pg, _ = _new_process_group_helper( + world_size, rank, [], backend, store, group_name, pg_options=pg_options, timeout=timeout + ) + _update_default_pg(default_pg) + + _world.pg_group_ranks[GroupMember.WORLD] = {i: i for i in range(GroupMember.WORLD.size())} # type: ignore[attr-defined, index] + + +def destroy_process_group(group: Optional[ProcessGroup] = None): + """ + Destroy a given process group, and deinitialize the distributed package. + + Args: + group (ProcessGroup, optional): The process group to be destroyed, if + group.WORLD is given, all process + groups including the default one will + be destroyed. + """ + global _world + + if group is None: + pg = GroupMember.WORLD + else: + pg = group + + if group is None or group == GroupMember.WORLD: + _update_default_pg(None) + _world.pg_names.clear() + _world.pg_group_ranks.clear() + _world.pg_to_tag.clear() + _world.tags_to_pg.clear() + _world.group_count = 0 + else: + del _world.pg_names[pg] + del _world.pg_group_ranks[pg] + tag = _world.pg_to_tag.get(pg) + del _world.pg_to_tag[pg] + if tag is not None: + try: + _world.tags_to_pg[tag].remove(pg) + if tag.startswith("ptd:"): + _world.tags_to_pg[""].remove(pg) + except Exception: + pass + + +def dump_nccl_graph(xmlfile="./ncclgraph.xml", pg=None, rank=0): + """ + Dump NCCL graph by runing torch.distributed.all_reduce. + + Args: + xmlfile (str, optional): The path to the xml file to dump the NCCL graph. + pg (ProcessGroup, optional): The process group to dump the NCCL graph. + rank (int, optional): The rank of the process to dump the NCCL graph. + """ + original_NCCL_GRAPH_DUMP_FILE = os.environ.get("NCCL_GRAPH_DUMP_FILE", None) + os.environ["NCCL_GRAPH_DUMP_FILE"] = xmlfile + tensor = torch.rand(1024).cuda(rank) + torch.distributed.all_reduce(tensor, op=torch.distributed.ReduceOp.SUM, group=pg) + if original_NCCL_GRAPH_DUMP_FILE is not None: + os.environ["NCCL_GRAPH_DUMP_FILE"] = original_NCCL_GRAPH_DUMP_FILE + else: + del os.environ["NCCL_GRAPH_DUMP_FILE"] + + +def dump_nccl_graph_for_pg(emulator_pg: ProcessGroup, torch_pg: torchProcessGroup, rank): + """ + Dump NCCL graph for a pair of given emulator process group and pytorch process group. + + Args: + emulator_pg (ProcessGroup): The emulator process group to dump the NCCL graph. + torch_pg (torch.distributed.ProcessGroup): The torch process group to dump the NCCL graph. + rank (int): The rank of the process to dump the NCCL graph. + """ + global _world + ranks_mapping = _world.pg_group_ranks[emulator_pg] + global_ranks = list(ranks_mapping.keys()) + global_ranks = sorted(global_ranks) + xmlfile = f"ncclgraph_{'_'.join(map(str, global_ranks))}.xml" + dump_nccl_graph(xmlfile, torch_pg, rank) + + +def delete_nccl_graph_for_pg(emulator_pg: ProcessGroup): + """ + Delete NCCL graph for a given emulator process group. + + Args: + emulator_pg (ProcessGroup): The emulator process group to delete the NCCL graph. + """ + global _world + ranks_mapping = _world.pg_group_ranks[emulator_pg] + global_ranks = list(ranks_mapping.keys()) + global_ranks = sorted(global_ranks) + xmlfile = f"ncclgraph_{'_'.join(map(str, global_ranks))}.xml" + if os.path.exists(xmlfile): + os.remove(xmlfile) + + +def get_nccl_graph_xml(pg=None): + """ + Get the path to the NCCL graph xml file for a given process group. + + Args: + pg (ProcessGroup, optional): The process group to get the NCCL graph xml file. + + Returns: + str: The path to the NCCL graph xml file. + """ + global _world + if pg is None: + pg = GroupMember.WORLD + ranks_mapping = _world.pg_group_ranks[pg] + global_ranks = list(ranks_mapping.keys()) + global_ranks = sorted(global_ranks) + return f"ncclgraph_{'_'.join(map(str, global_ranks))}.xml" diff --git a/vescale/emulator/emulator_instrumentation.py b/vescale/emulator/emulator_instrumentation.py new file mode 100644 index 0000000..f9f35db --- /dev/null +++ b/vescale/emulator/emulator_instrumentation.py @@ -0,0 +1,110 @@ +################################################################################ +# +# Copyright 2023 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ + +from functools import wraps + + +def decorate_function(func, indices): + @wraps(func) + def wrapper(*args, **kwargs): + outputs = [] + for rank in range(len(args[indices[0]])): + new_args = list(args) + for i in indices: + new_args[i] = args[i][rank] + o = func(*new_args, **kwargs) + outputs.append(o) + return outputs + + if not callable(func): + return func + + return wrapper + + +def instrument(obj, func_name_str, indices): + func_name_list = func_name_str.split(".") + func_name = func_name_list[-1] + + module_obj = obj + if len(func_name_list) > 1: + for module_name in func_name_list[:-1]: + module_obj = getattr(module_obj, module_name) + orig_func = getattr(module_obj, func_name) + + wrapped_func = decorate_function(orig_func, indices) + setattr(module_obj, func_name, wrapped_func) + return orig_func + + +def revert_instrument(obj, func_name_str, orig_func): + func_name_list = func_name_str.split(".") + func_name = func_name_list[-1] + + module_obj = obj + if len(func_name_list) > 1: + for module_name in func_name_list[:-1]: + module_obj = getattr(module_obj, module_name) + + setattr(module_obj, func_name, orig_func) + return orig_func + + +def instrument_all(obj, func_name_str_list, indices_list): + orig_func_dict = {} + for func_name_str, indices in zip(func_name_str_list, indices_list): + orig_func = instrument(obj, func_name_str, indices) + orig_func_dict[func_name_str] = orig_func + return orig_func_dict + + +def revert_instrument_all(obj, func_name_str_list, orig_func_dict): + for func_name_str in func_name_str_list: + orig_func = orig_func_dict[func_name_str] + revert_instrument(obj, func_name_str, orig_func) + + +class EmulatorInstrumentation: + """ + A context manager to instrument emulator functions. It replaces the original function with + a wrapper function that iterates on a list of inputs and returns a list of outputs. + + Args: + obj (object): The object to instrument. E.g., torch. + func_name_str_list (List[str]): A list of function names to instrument. E.g., ["mm"] + indices_list (List[List[int]]): A list of indices to instrument. E.g., [[0, 1]] + + Example: + >>> import torch + >>> import vescale.emulator as emu + >>> from vescale.emulator.emulator_instrumentation import EmulatorInstrumentation + >>> t1 = [torch.randn(2, 3), torch.randn(2, 3)] + >>> t2 = [torch.randn(3, 4), torch.randn(3, 4)] + >>> with EmulatorInstrumentation(torch, ["mm"], [[0, 1]]): + >>> torch.mm(t1, t2) + """ + + def __init__(self, obj, func_name_str_list, indices_list) -> None: + self.obj = obj + self.func_name_str_list = func_name_str_list + self.orig_func_dict = instrument_all(obj, func_name_str_list, indices_list) + + def __enter__(self) -> None: + pass + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + revert_instrument_all(self.obj, self.func_name_str_list, self.orig_func_dict) diff --git a/vescale/emulator/mesh_collectives.py b/vescale/emulator/mesh_collectives.py new file mode 100644 index 0000000..4dd8fc8 --- /dev/null +++ b/vescale/emulator/mesh_collectives.py @@ -0,0 +1,211 @@ +################################################################################ +# +# Copyright 2023 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ + +import torch +from vescale.emulator.device_mesh import DeviceMesh +from vescale.emulator.reduce_kernel import ReduceOp +from typing import List + + +def mesh_all_gather( + tensors: List[torch.Tensor], + mesh: DeviceMesh, + scatter_dim: int, + mesh_dim: int, +) -> List[torch.Tensor]: + """ + all_gather all shards and return a tensor that is replicated + on the previously sharded mesh dimension + """ + tag_rank_list = mesh._dim_group_infos[mesh_dim] + dim_group = mesh.get_dim_groups()[mesh_dim] + + results = [0 for _ in range(len(tensors))] + for (tag, ranks), pg in zip(tag_rank_list, dim_group): + inputs = [] + outputs_list = [] + for rank in ranks: + inputs.append(tensors[rank]) + outputs_list.append([torch.empty_like(tensors[rank]) for _ in range(len(ranks))]) + + pg.all_gather(outputs_list, inputs) + + for i, outputs in enumerate(outputs_list): + outputs_list[i] = torch.cat(outputs, dim=scatter_dim) + + for i, rank in enumerate(ranks): + results[rank] = outputs_list[i] + + return results + + +def mesh_all_reduce( + tensors: List[torch.Tensor], + mesh: DeviceMesh, + reduce_op: ReduceOp, + mesh_dim: int, + tree_structure=None, +) -> List[torch.Tensor]: + """ + all_reduce all tensors in the list and return a tensor that is replicated + on the previously sharded mesh dimension + """ + tag_rank_list = mesh._dim_group_infos[mesh_dim] + dim_group = mesh.get_dim_groups()[mesh_dim] + + results = [0 for _ in range(len(tensors))] + for (tag, ranks), pg in zip(tag_rank_list, dim_group): + inputs = [] + for rank in ranks: + inputs.append(tensors[rank]) + pg.all_reduce(inputs, op=reduce_op, tree_structure=tree_structure) + + for i, rank in enumerate(ranks): + results[rank] = inputs[i] + + return results + + +def mesh_reduce_scatter( + tensors: List[torch.Tensor], + mesh: DeviceMesh, + reduce_op: ReduceOp, + scatter_dim: int, + mesh_dim: int, +): + """ + First peform all_reduce on the tensor, then split the tensor at scatter_dim + and scatter them to a device mesh dimension. + """ + tag_rank_list = mesh._dim_group_infos[mesh_dim] + dim_group = mesh.get_dim_groups()[mesh_dim] + + results = [0 for _ in range(len(tensors))] + for (tag, ranks), pg in zip(tag_rank_list, dim_group): + inputs = [] + outputs = [] + for rank in ranks: + split_size = tensors[rank].size()[scatter_dim] // len(ranks) + input_list = torch.split(tensors[rank], split_size, dim=scatter_dim) + output = torch.empty_like(input_list[0]) + outputs.append(output) + inputs.append(input_list) + pg.reduce_scatter(outputs, inputs, op=reduce_op) + + for i, rank in enumerate(ranks): + results[rank] = outputs[i] + + return results + + +def mesh_all_to_all( + output_tensor_list: List[List[torch.Tensor]], + input_tensor_list: List[List[torch.Tensor]], + mesh: DeviceMesh, + mesh_dim: int = 0, + async_op: bool = False, +): + """ + Perform all_to_all on the tensor list. + """ + tag_rank_list = mesh._dim_group_infos[mesh_dim] + dim_group = mesh.get_dim_groups(mesh_dim) + + for (tag, ranks), pg in zip(tag_rank_list, dim_group): + inputs = [] + outputs = [] + for rank in ranks: + inputs.append(input_tensor_list[rank]) + outputs.append(input_tensor_list[rank]) + pg.all_to_all(outputs, inputs) + + for i, rank in enumerate(ranks): + output_tensor_list[rank] = outputs[i] + + return output_tensor_list + + +def mesh_broadcast( + tensors: List[torch.Tensor], + mesh: DeviceMesh, + mesh_dim: int = 0, + async_op=False, +) -> List[torch.Tensor]: + """ + broadcast the tensor to a device mesh dimension. We by default + use the first rank of the mesh dimension as the source of truth, i.e + for a 2d mesh [[0, 1], [2, 3]], if we broadcast on mesh_dim = 1, we will + broadcast the tensor on rank 0 to rank 0/1, and tensor on rank 2 + to rank 2/3. + + Args: + tensors (List[torch.Tensor]): tensor to broadcast. + mesh_dim (int, optional): indicate which mesh dimension we want + to broadcast on, we by default choose the first rank on the + mesh dimension as source of truth. + + Returns: + A list of :class:`Tensor` object + """ + + # NOTE: funcol impl already check and force tensor contiguous, we do nothing here. + tag_rank_list = mesh._dim_group_infos[mesh_dim] + dim_group = mesh.get_dim_groups()[mesh_dim] + + results = [0 for _ in range(len(tensors))] + for (tag, ranks), pg in zip(tag_rank_list, dim_group): + source = ranks[0] + for rank in ranks: + results[rank] = tensors[source] + + return results + + +def mesh_scatter( + outputs: List[torch.Tensor], + scatter_list_list: List[List[torch.Tensor]], + mesh: DeviceMesh, + mesh_dim: int = 0, + async_op: bool = False, +) -> torch.Tensor: + """ + scatter a list of tensors to a device mesh dimension. We by default + use the first rank of the mesh dimension as the source of truth, i.e + for a 2d mesh [[0, 1], [2, 3]], if we scatter on mesh_dim = 1, we will + scatter the tensor list on rank 0 to rank 0/1, and tensor list on rank + 2 to rank 2/3. + + Args: + outputs (List[torch.Tensor]): the tensor to receive the scattered list. + scatters_list (List[List[torch.Tensor]]): the tensor list to be scattered. + mesh (DeviceMesh): device mesh. + mesh_dim (int, optional): indicate which mesh dimension we want + to scatter on, we by default choose the first rank on the + mesh dimension as source of truth. + + Returns: + A list of :class:`torch.Tensor` object + """ + tag_rank_list = mesh._dim_group_infos[mesh_dim] + dim_group = mesh.get_dim_groups()[mesh_dim] + + for (tag, ranks), pg in zip(tag_rank_list, dim_group): + source = ranks[0] + for i, rank in enumerate(ranks): + outputs[rank] = scatter_list_list[source][i] + + return outputs diff --git a/vescale/emulator/nccl/__init__.py b/vescale/emulator/nccl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vescale/emulator/nccl/constants.py b/vescale/emulator/nccl/constants.py new file mode 100644 index 0000000..e94bf08 --- /dev/null +++ b/vescale/emulator/nccl/constants.py @@ -0,0 +1,159 @@ +################################################################################ +# +# Copyright 2023 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ +# Some code comes from NCCL +# Original license: +# Copyright (c) 2016-2022, NVIDIA CORPORATION. All rights reserved. +# +# See LICENSE.txt for license information +################################################################################ + +from enum import Enum + +NCCL_NUM_ALGORITHMS = 2 # Tree/Ring/CollNet* +NCCL_ALGO_UNDEF = -1 +NCCL_ALGO_TREE = 0 +NCCL_ALGO_RING = 1 +# TODO: add more algorithms +# NCCL_ALGO_COLLNET_DIRECT = 2 +# NCCL_ALGO_COLLNET_CHAIN = 3 +# NCCL_ALGO_NVLS = 4 +# NCCL_ALGO_NVLS_TREE = 5 + +NCCL_NUM_PROTOCOLS = 3 # Simple/LL/LL128 +NCCL_PROTO_UNDEF = -1 +NCCL_PROTO_LL = 0 +NCCL_PROTO_LL128 = 1 +NCCL_PROTO_SIMPLE = 2 + +sizeof_uint64_t = 8 # assume on 64bit platform + +NCCL_STEPS = 8 +ALLREDUCE_SLICESTEPS = int(NCCL_STEPS / 4) +ALLREDUCE_CHUNKSTEPS = int(NCCL_STEPS / 2) +REDUCESCATTER_SLICESTEPS = int(NCCL_STEPS / 4) +REDUCESCATTER_CHUNKSTEPS = int(NCCL_STEPS / 2) + +WARP_SIZE = 32 +MAXCHANNELS = 32 +NCCL_MAX_NTHREADS = 640 +NCCL_SIMPLE_MAX_NTHREADS = 512 +NCCL_LL_MAX_NTHREADS = 512 +NCCL_LL_LINES_PER_THREAD = 8 + +NCCL_LL128_LINESIZE = 128 +NCCL_LL128_LINEELEMS = int(NCCL_LL128_LINESIZE / sizeof_uint64_t) +NCCL_LL128_DATAELEMS = NCCL_LL128_LINEELEMS - 1 + +NCCL_LL128_MAX_NTHREADS = 640 +NCCL_LL128_ELEMS_PER_THREAD = 120 + +NCCL_LL128_SHMEM_ELEMS_PER_THREAD = 8 + +PCI_BW = 12.0 + +NCCL_LL_THREAD_THRESHOLD = 8 +NCCL_LL128_THREAD_THRESHOLD = 8 +NCCL_SIMPLE_THREAD_THRESHOLD = 64 + + +NCCL_NUM_FUNCTIONS = 5 + + +class NcclFunc(Enum): + ncclFuncBroadcast = 0 + ncclFuncReduce = 1 + ncclFuncAllGather = 2 + ncclFuncReduceScatter = 3 + ncclFuncAllReduce = 4 + ncclFuncSendRecv = 5 + ncclFuncSend = 6 + ncclFuncRecv = 7 + ncclNumFuncs = 8 + + +def log2i(n: int) -> int: + l = 0 + n >>= 1 + while n: + l += 1 + n >>= 1 + return l + + +def div_up(x, y): + return (x + y - 1) // y + + +def round_up(x, y): + return (x + y - 1) - (x + y - 1) % y + + +def align_up(x, a): + return (x + a - 1) & (-a) + + +def ALIGN_SIZE(size, align): + size = ((size + (align) - 1) // (align)) * (align) + + +# #define NCCL_MAX_WORK_ELEMENTS ((NCCL_WORK_SIZE - alignUp(sizeof(ncclWorkHeader), alignof(ncclWorkElem)))/sizeof(ncclWorkElem)) +# static_assert(NCCL_MAX_WORK_ELEMENTS == 9, "Sanity check: NCCL_MAX_WORK_ELEMENTS == 9"); +NCCL_MAX_WORK_ELEMENTS = 9 + + +# Define constants to match the C code definitions +NCCL_TOPO_CPU_ARCH_X86 = 1 +NCCL_TOPO_CPU_ARCH_POWER = 2 +NCCL_TOPO_CPU_ARCH_ARM = 3 +NCCL_TOPO_CPU_VENDOR_INTEL = 1 +NCCL_TOPO_CPU_VENDOR_AMD = 2 +NCCL_TOPO_CPU_VENDOR_ZHAOXIN = 3 +NCCL_TOPO_CPU_TYPE_BDW = 1 +NCCL_TOPO_CPU_TYPE_SKL = 2 +NCCL_TOPO_CPU_TYPE_YONGFENG = 1 + + +LINK_LOC = 0 +LINK_NVL = 1 +# Skipping 2 for PATH_NVB +LINK_PCI = 3 +# Skipping 4 for PATH_PXB +# Skipping 5 for PATH_PXN +# Skipping 6 for PATH_PHB +LINK_SYS = 7 +LINK_NET = 8 + +sizeof_union_ncclLLFifoLine = 16 +DEFAULT_LL_BUFFSIZE = NCCL_LL_LINES_PER_THREAD * NCCL_LL_MAX_NTHREADS * NCCL_STEPS * sizeof_union_ncclLLFifoLine +DEFAULT_LL128_BUFFSIZE = NCCL_LL128_ELEMS_PER_THREAD * NCCL_LL128_MAX_NTHREADS * NCCL_STEPS * sizeof_uint64_t +DEFAULT_BUFFSIZE = 1 << 22 # 4MiB + + +class NcclPattern(Enum): + Ring = 0 + RingTwice = 1 + # PipelineFrom = 2 + # PipelineTo = 3 + # TreeUp = 4 + # TreeDown = 5 + TreeUpDown = 6 + # CollnetChain = 7 + # CollnetDirect = 8 + # Nvls = 9 + # NvlsTree = 10 + # Send = 11 + # Recv = 12 diff --git a/vescale/emulator/nccl/graph/__init__.py b/vescale/emulator/nccl/graph/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vescale/emulator/nccl/graph/tuning.py b/vescale/emulator/nccl/graph/tuning.py new file mode 100644 index 0000000..66a1006 --- /dev/null +++ b/vescale/emulator/nccl/graph/tuning.py @@ -0,0 +1,388 @@ +################################################################################ +# +# Copyright 2023 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ +# Some code comes from tuning.cc in NCCL +# Original license: +# Copyright (c) 2016-2022, NVIDIA CORPORATION. All rights reserved. +# +# See LICENSE.txt for license information +################################################################################ + +from typing import List + +from vescale.emulator.nccl.include.info import NcclInfo +from vescale.emulator.nccl.include.comm import NcclComm +from vescale.emulator.nccl.include.graph import * # noqa: F403 +from vescale.emulator.nccl.constants import * # noqa: F403 +import platform +import subprocess + +VOLTA_COMPCAP_IDX = 0 +AMPERE_COMPCAP_IDX = 1 +HOPPER_COMPCAP_IDX = 2 + +llMaxBws = [[39.0, 39.0, 20.4], [87.7, 22.5, 19.0], [87.7, 22.5, 19.0]] + +perChMaxRingLL128Bws = [[20.0, 20.0, 20.0], [20.0, 20.0, 20.0], [36.7, 36.7, 36.7]] + +perChMaxTreeLL128Bws = [[20.0, 20.0, 20.0], [20.0, 20.0, 20.0], [36.7, 36.7, 29.0]] + +perChMaxTreeBws = [[26.5, 18.5, 10.0], [24.0, 23.6, 17.8], [38.7, 41.4, 36.0]] + +# Constants +NCCL_HW_NVLINK = 0 +NCCL_HW_PCI = 1 +NCCL_HW_NET = 2 +VOLTA_COMPCAP_IDX = 0 +AMPERE_COMPCAP_IDX = 1 +HOPPER_COMPCAP_IDX = 2 + +baseLat = [[6.8, 14.0, 0.0], [6.6, 14.0, 8.4], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]] + +hwLat = [ + # NVLINK + [[0.6, 1.25, 28.0], [0.6, 1.9, 3.4], [0.0, 0.0, 3.7], [0.0, 0.0, 2.8], [0.0, 0.0, 23.0], [0.0, 0.0, 23.0]], + # PCI + [[1.0, 1.9, 28.0], [1.0, 2.5, 5.7], [0.0, 0.0, 3.7], [0.0, 0.0, 2.8], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], + # NET + [[5.0, 8.5, 28.0], [2.7, 4.0, 14.0], [0.0, 0.0, 31.0], [0.0, 0.0, 30.0], [0.0, 0.0, 18.0], [0.0, 0.0, 14.0]], +] + +NCCL_NTHREADS = -2 +NCCL_LL128_NTHREADS = -2 + + +def get_nthreads(name: str, env: int, min_v: int, max_v: int, default: int): + nt = env + if nt > 0: + if nt % WARP_SIZE != 0: + nt = max_v + elif nt > max_v: + nt = max_v + elif nt < min_v: + nt = min_v + else: + nt = default + return nt + + +# Define the mappings +cpu_arch_map = { + "x86_64": NCCL_TOPO_CPU_ARCH_X86, + "ppc64le": NCCL_TOPO_CPU_ARCH_POWER, + "aarch64": NCCL_TOPO_CPU_ARCH_ARM, +} + +cpu_vendor_map = { + "GenuineIntel": NCCL_TOPO_CPU_VENDOR_INTEL, + "AuthenticAMD": NCCL_TOPO_CPU_VENDOR_AMD, + "Shanghai": NCCL_TOPO_CPU_VENDOR_ZHAOXIN, # Example for Zhaoxin vendor +} + +cpu_model_map = { + "Broadwell": NCCL_TOPO_CPU_TYPE_BDW, + "Skylake": NCCL_TOPO_CPU_TYPE_SKL, + "Yongfeng": NCCL_TOPO_CPU_TYPE_YONGFENG, # Example for Yongfeng model +} + + +def get_cpu_info(): + """Get CPU information using platform and subprocess modules.""" + cpu_arch = platform.machine() + + # Get CPU vendor and model using lscpu on Linux + if platform.system() == "Linux": + try: + lscpu_output = subprocess.check_output("lscpu", shell=True).decode().split("\n") + cpu_vendor = "" + cpu_model = "" + for line in lscpu_output: + if "Vendor ID:" in line: + cpu_vendor = line.split(":")[1].strip() + elif "Model name:" in line: + cpu_model = line.split(":")[1].strip().split()[0] # Simplified for this example + return cpu_arch, cpu_vendor, cpu_model + except Exception as e: + print(f"Error retrieving CPU info: {e}") + return cpu_arch, "Unknown Vendor", "Unknown Model" + + # For other OSes (macOS, Windows), use platform module or other methods + elif platform.system() == "Darwin": # macOS + try: + cpu_vendor = subprocess.check_output("sysctl -n machdep.cpu.vendor", shell=True).decode().strip() + cpu_model = ( + subprocess.check_output("sysctl -n machdep.cpu.brand_string", shell=True).decode().strip().split()[0] + ) + return cpu_arch, cpu_vendor, cpu_model + except Exception as e: + print(f"Error retrieving CPU info: {e}") + return cpu_arch, "Unknown Vendor", "Unknown Model" + + elif platform.system() == "Windows": + try: + cpu_vendor = platform.processor() + cpu_model = platform.processor() # Windows often reports the same for both + return cpu_arch, cpu_vendor, cpu_model + except Exception as e: + print(f"Error retrieving CPU info: {e}") + return cpu_arch, "Unknown Vendor", "Unknown Model" + + else: + return cpu_arch, "Unknown Vendor", "Unknown Model" + + +def ncclTopoCpuType(): + cpu_arch, cpu_vendor, cpu_model = get_cpu_info() + + arch_code = cpu_arch_map.get(cpu_arch, None) + vendor_code = cpu_vendor_map.get(cpu_vendor, None) + model_code = cpu_model_map.get(cpu_model, None) + + return arch_code, vendor_code, model_code + + +def getNetOverhead(comm: NcclComm): + cpuArch, cpuVendor, cpuModel = ncclTopoCpuType() + if cpuArch == NCCL_TOPO_CPU_ARCH_X86 and cpuVendor == NCCL_TOPO_CPU_VENDOR_INTEL: + return 1.0 + elif cpuArch == NCCL_TOPO_CPU_ARCH_X86 and cpuVendor == NCCL_TOPO_CPU_VENDOR_AMD: + return 2.0 + else: + return 1.0 + + +def nccl_topo_tune_model(comm: NcclComm, minCompCap: int, maxCompCap: int, graphs: List[NcclTopoGraph]): + if graphs[NCCL_ALGO_RING].bwIntra * graphs[NCCL_ALGO_RING].nChannels <= PCI_BW: + simpleDefaultThreads = 256 + else: + simpleDefaultThreads = NCCL_SIMPLE_MAX_NTHREADS + + comm.max_threads[NCCL_ALGO_RING][NCCL_PROTO_SIMPLE] = get_nthreads( + "NCCL_NTHREADS", NCCL_NTHREADS, 2 * WARP_SIZE, NCCL_SIMPLE_MAX_NTHREADS, simpleDefaultThreads + ) + comm.max_threads[NCCL_ALGO_TREE][NCCL_PROTO_SIMPLE] = get_nthreads( + "NCCL_NTHREADS", NCCL_NTHREADS, 2 * WARP_SIZE, NCCL_SIMPLE_MAX_NTHREADS, NCCL_SIMPLE_MAX_NTHREADS + ) + comm.max_threads[NCCL_ALGO_RING][NCCL_PROTO_LL] = comm.max_threads[NCCL_ALGO_TREE][NCCL_PROTO_LL] = get_nthreads( + "NCCL_NTHREADS", NCCL_NTHREADS, 2 * WARP_SIZE, NCCL_LL_MAX_NTHREADS, NCCL_LL_MAX_NTHREADS + ) + comm.max_threads[NCCL_ALGO_RING][NCCL_PROTO_LL128] = comm.max_threads[NCCL_ALGO_TREE][NCCL_PROTO_LL128] = ( + get_nthreads( + "NCCL_LL128_NTHREADS", + NCCL_LL128_NTHREADS, + NCCL_LL128_MAX_NTHREADS / 4, + NCCL_LL128_MAX_NTHREADS, + NCCL_LL128_MAX_NTHREADS, + ) + ) + + nNodes = comm.nNodes + nRanks = comm.nRanks + if nRanks <= 1: + return + + compCapIndex = ( + HOPPER_COMPCAP_IDX if minCompCap >= 90 else AMPERE_COMPCAP_IDX if minCompCap >= 80 else VOLTA_COMPCAP_IDX + ) + cpuArch, cpuVendor, cpuModel = ncclTopoCpuType() + + index2 = nNodes - 1 if nNodes <= 2 else 2 + + index1 = compCapIndex if nNodes == 1 else 1 if cpuVendor == NCCL_TOPO_CPU_VENDOR_AMD else 0 + + llMaxBw = llMaxBws[index1][index2] + perChMaxTreeBw = perChMaxTreeBws[compCapIndex][index2] + perChMaxRingLL128Bw = perChMaxRingLL128Bws[compCapIndex][index2] + perChMaxTreeLL128Bw = perChMaxTreeLL128Bws[compCapIndex][index2] + + if cpuArch == NCCL_TOPO_CPU_ARCH_POWER: + hwLat[NCCL_HW_PCI][NCCL_ALGO_TREE][NCCL_PROTO_SIMPLE] = hwLat[NCCL_HW_PCI][NCCL_ALGO_RING][NCCL_PROTO_SIMPLE] + + ppn = float(nRanks) / nNodes + + intraHw = [NCCL_HW_NVLINK if graphs[a].typeIntra == LINK_NVL else NCCL_HW_PCI for a in range(NCCL_NUM_ALGORITHMS)] + hw = [intraHw[a] if nNodes == 1 else NCCL_HW_NET for a in range(NCCL_NUM_ALGORITHMS)] + + for coll_i in range(NCCL_NUM_FUNCTIONS): + coll = NcclFunc(coll_i) + if coll == NcclFunc.ncclFuncAllReduce: + nsteps = 2 * (nRanks - 1) + elif coll == NcclFunc.ncclFuncReduceScatter or coll == NcclFunc.ncclFuncAllGather: + nsteps = nRanks - 1 + else: + nsteps = nRanks + + if coll == NcclFunc.ncclFuncAllReduce: + if nNodes > 1: + nInterSteps = 2 * nNodes + else: + nInterSteps = 0 + elif coll == NcclFunc.ncclFuncReduceScatter or coll == NcclFunc.ncclFuncAllGather: + nInterSteps = nNodes - 1 + else: + nInterSteps = nNodes + + for a in range(NCCL_NUM_ALGORITHMS): + if coll == NcclFunc.ncclFuncBroadcast and a != NCCL_ALGO_RING: + continue + if coll == NcclFunc.ncclFuncReduce and a != NCCL_ALGO_RING: + continue + if coll == NcclFunc.ncclFuncReduceScatter and a != NCCL_ALGO_RING: # and a != NCCL_ALGO_NVLS: + continue + if coll == NcclFunc.ncclFuncAllGather and a != NCCL_ALGO_RING: # and a != NCCL_ALGO_NVLS: + continue + + for p in range(NCCL_NUM_PROTOCOLS): + # if (a == NCCL_ALGO_NVLS or a == NCCL_ALGO_NVLS_TREE) and p != NCCL_PROTO_SIMPLE: + # continue + # collnet = (a == NCCL_ALGO_COLLNET_DIRECT or a == NCCL_ALGO_COLLNET_CHAIN) + if nNodes <= 2: # or collnet: + bw = graphs[a].bwIntra + else: + bw = graphs[a].bwInter + # if a == NCCL_ALGO_NVLS: + # bw = min(graphs[a].bwIntra, graphs[a].bwInter) + # if a == NCCL_ALGO_NVLS_TREE: + # if nNodes <= 2: + # tmp_bwInter = graphs[a].bwInter + # else: + # tmp_bwInter = graphs[a].bwInter/2 + # bw = min(graphs[a].bwIntra, tmp_bwInter) + busBw = graphs[a].nChannels * bw + + # Various model refinements + if a == NCCL_ALGO_RING and p == NCCL_PROTO_LL: + busBw = min( + llMaxBw, + busBw + * ( + 1.0 / 4.0 + if (nNodes > 1 or coll in [NcclFunc.ncclFuncAllReduce, NcclFunc.ncclFuncReduce]) + else 1.0 / 3.0 + ), + ) + if a == NCCL_ALGO_RING and p == NCCL_PROTO_LL128: + busBw = min(busBw * (0.7 if ppn < 2 else 0.92), graphs[a].nChannels * perChMaxRingLL128Bw) + if a == NCCL_ALGO_TREE: + busBw = min(busBw * 0.92, graphs[a].nChannels * perChMaxTreeBw) + if a == NCCL_ALGO_TREE and p == NCCL_PROTO_LL: + busBw = min(busBw * 1.0 / 3.8, llMaxBw) + if a == NCCL_ALGO_TREE and p == NCCL_PROTO_LL128: + busBw = min( + busBw * (7.0 / 9.0 if nNodes == 1 else 120.0 / 128.0), graphs[a].nChannels * perChMaxTreeLL128Bw + ) + if a == NCCL_ALGO_TREE and graphs[a].pattern == NCCL_TOPO_PATTERN_TREE: + busBw *= 0.85 + # skip collnet direct/chain for now + + # Convert bus BW to algorithm BW + if a == NCCL_ALGO_RING: + ratio = (1.0 * nRanks) / nsteps + # elif a == NCCL_ALGO_NVLS or a == NCCL_ALGO_NVLS_TREE: + # ratio = 5.0/6.0 + else: + ratio = 0.5 + comm.bandwidths[coll][a][p] = busBw * ratio + # Ring bandwidth backup + if a == NCCL_ALGO_RING: + comm.ringbdw[coll][p] = comm.bandwidths[coll][NCCL_ALGO_RING][p] + comm.latencies[coll][a][p] = baseLat[a][p] + intraLat = hwLat[intraHw[a]][a][p] + interLat = hwLat[NCCL_HW_NET][a][p] + graphs[a].latencyInter + # Also add the flush extra latency + if p == NCCL_PROTO_SIMPLE: + interLat += graphs[a].latencyInter + + if a == NCCL_ALGO_RING: + lat = hwLat[hw[a]][a][p] + if coll == NcclFunc.ncclFuncReduce or coll == NcclFunc.ncclFuncBroadcast: + if graphs[a].sameChannels: + comm.latencies[coll][a][p] += lat + else: + if p == NCCL_PROTO_SIMPLE: + lat = hwLat[hw[a]][NCCL_ALGO_TREE][ + p + ] # Add some chunk latency, waiting for proper chunk modeling + comm.latencies[coll][a][p] += nsteps * lat + else: + # Inter-node rings still have to launch nsteps * net overhead. + netOverhead = 0.0 + if nNodes > 1: + netOverhead = getNetOverhead(comm) + if p == NCCL_PROTO_SIMPLE: + netOverhead *= 3 + intraLat = max(intraLat, netOverhead) + comm.latencies[coll][a][p] += (nsteps - nInterSteps) * intraLat + nInterSteps * interLat + elif a == NCCL_ALGO_TREE: + comm.latencies[coll][a][p] += 2 * ((nRanks / nNodes - 1) * intraLat + log2i(nNodes) * interLat) + else: + # skip collnet direct/chain for now + raise NotImplementedError + + comm.thread_thresholds[NCCL_ALGO_RING][NCCL_PROTO_LL] = comm.thread_thresholds[NCCL_ALGO_TREE][NCCL_PROTO_LL] = ( + NCCL_LL_THREAD_THRESHOLD + ) + comm.thread_thresholds[NCCL_ALGO_RING][NCCL_PROTO_LL128] = comm.thread_thresholds[NCCL_ALGO_TREE][ + NCCL_PROTO_LL128 + ] = NCCL_LL128_THREAD_THRESHOLD + comm.thread_thresholds[NCCL_ALGO_RING][NCCL_PROTO_SIMPLE] = comm.thread_thresholds[NCCL_ALGO_TREE][ + NCCL_PROTO_SIMPLE + ] = NCCL_SIMPLE_THREAD_THRESHOLD + comm.thread_thresholds[NCCL_ALGO_RING][NCCL_PROTO_LL] *= nRanks + return comm + + +tree_correction_factor = [ + [1.0, 1.0, 1.0, 1.0, 0.9, 0.8, 0.7, 0.7, 0.7, 0.7, 0.6, 0.5, 0.4, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.0, 1.0, 1.0], + [1.0, 1.0, 1.0, 1.0, 1.0, 0.9, 0.8, 0.8, 0.8, 0.7, 0.6, 0.6, 0.6, 0.6, 0.6, 0.6, 0.8, 0.9, 0.9, 0.9, 0.9, 1.0, 1.0], + [0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.8, 0.7, 0.6, 0.6, 0.5, 0.5, 0.5, 0.5, 0.6, 0.7, 0.8, 0.7, 0.7, 0.8, 0.9, 0.9], +] + + +def DIVUP(x, y): + return ((x) + (y) - 1) / (y) + + +def nccl_topo_get_algo_time(info: NcclInfo, algorithm: int, protocol: int, numPipeOps: int): + bw = info.comm.bandwidths[info.coll][algorithm][protocol] + lat = info.comm.latencies[info.coll][algorithm][protocol] + backup = False + + if algorithm == NCCL_ALGO_RING and bw == 0.0: + # Try backup RING algorithm + bw = info.comm.ringbdw[info.coll][protocol] + backup = True + + if bw == 0: + return -1.0, backup + + logSize = int(log2i(info.nBytes >> 6)) + if algorithm == NCCL_ALGO_TREE and logSize < 23: + bw *= tree_correction_factor[protocol][logSize] + # if info.nChannels != 0: + # bw = bw / info.comm.nChannels * info.nChannels + if ( + algorithm == NCCL_ALGO_RING + and protocol == NCCL_PROTO_SIMPLE + and info.comm.nNodes > 1 + and info.coll == NcclFunc.ncclFuncAllReduce + and info.nBytes / (info.comm.nChannels * info.comm.nRanks) >= 64 + ): + lat *= 1.9 if info.comm.minCompCap < 80 else 1.4 # Plateau effect of ring + + latCount = numPipeOps if algorithm == NCCL_ALGO_RING else DIVUP(numPipeOps, NCCL_MAX_WORK_ELEMENTS) + time = lat * latCount + info.nBytes / (1000 * bw) + return time, backup diff --git a/vescale/emulator/nccl/include/__init__.py b/vescale/emulator/nccl/include/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vescale/emulator/nccl/include/comm.py b/vescale/emulator/nccl/include/comm.py new file mode 100644 index 0000000..20d121e --- /dev/null +++ b/vescale/emulator/nccl/include/comm.py @@ -0,0 +1,40 @@ +################################################################################ +# +# Copyright 2023 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ +# Some code comes from comm.h in NCCL +# Original license: +# Copyright (c) 2016-2022, NVIDIA CORPORATION. All rights reserved. +# +# See LICENSE.txt for license information +################################################################################ + +import collections +from dataclasses import dataclass + + +@dataclass +class NcclComm: + nChannels: int = 0 + nNodes: int = 0 + nRanks: int = 0 + minCompCap: int = 0 + + bandwidths = collections.defaultdict(lambda: collections.defaultdict(lambda: collections.defaultdict(lambda: 0))) + latencies = collections.defaultdict(lambda: collections.defaultdict(lambda: collections.defaultdict(lambda: 0))) + ringbdw = collections.defaultdict(lambda: collections.defaultdict(lambda: 0)) + max_threads = collections.defaultdict(lambda: collections.defaultdict(lambda: 0)) + thread_thresholds = collections.defaultdict(lambda: collections.defaultdict(lambda: 0)) + buff_sizes = collections.defaultdict(lambda: 0) diff --git a/vescale/emulator/nccl/include/graph.py b/vescale/emulator/nccl/include/graph.py new file mode 100644 index 0000000..83be658 --- /dev/null +++ b/vescale/emulator/nccl/include/graph.py @@ -0,0 +1,69 @@ +################################################################################ +# +# Copyright 2023 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ +# Some code comes from graph.h in NCCL +# Original license: +# Copyright (c) 2016-2022, NVIDIA CORPORATION. All rights reserved. +# +# See LICENSE.txt for license information +################################################################################ + +from dataclasses import dataclass + +from vescale.emulator.nccl.constants import * # noqa: F403 + +NCCL_TOPO_CPU_ARCH_X86 = 1 +NCCL_TOPO_CPU_ARCH_POWER = 2 +NCCL_TOPO_CPU_ARCH_ARM = 3 +NCCL_TOPO_CPU_VENDOR_INTEL = 1 +NCCL_TOPO_CPU_VENDOR_AMD = 2 +NCCL_TOPO_CPU_VENDOR_ZHAOXIN = 3 +NCCL_TOPO_CPU_TYPE_BDW = 1 +NCCL_TOPO_CPU_TYPE_SKL = 2 +NCCL_TOPO_CPU_TYPE_YONGFENG = 1 + +NCCL_TOPO_MAX_NODES = 256 + +NCCL_TOPO_PATTERN_BALANCED_TREE = 1 +NCCL_TOPO_PATTERN_SPLIT_TREE = 2 +NCCL_TOPO_PATTERN_TREE = 3 +NCCL_TOPO_PATTERN_RING = 4 +NCCL_TOPO_PATTERN_NVLS = 5 + + +@dataclass +class NcclTopoGraph: + # Input / output + # id: int + pattern: int # used + # crossNic: int + # collNet: int + # minChannels: int + # maxChannels: int + + # Output + nChannels: int # used + bwIntra: float # used + bwInter: float # used + latencyInter: float # used + typeIntra: int # used + # typeInter: int + sameChannels: int # used + # nHops: int + + # Arrays + # intra: List[int] = field(default_factory=lambda: [0] * (MAXCHANNELS * NCCL_TOPO_MAX_NODES)) + # inter: List[int] = field(default_factory=lambda: [0] * (MAXCHANNELS * 2)) diff --git a/vescale/emulator/nccl/include/info.py b/vescale/emulator/nccl/include/info.py new file mode 100644 index 0000000..cb36c16 --- /dev/null +++ b/vescale/emulator/nccl/include/info.py @@ -0,0 +1,58 @@ +################################################################################ +# +# Copyright 2023 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ +# Some code comes from info.h in NCCL +# Original license: +# Copyright (c) 2016-2022, NVIDIA CORPORATION. All rights reserved. +# +# See LICENSE.txt for license information +################################################################################ + +from dataclasses import dataclass +from typing import Any + +import torch + +from vescale.emulator.nccl.constants import NcclFunc, NcclPattern +from vescale.emulator.nccl.include.comm import NcclComm + + +@dataclass +class NcclInfo: + coll: NcclFunc + comm: NcclComm + chunkSteps: int + sliceSteps: int + nChannels: int = 0 + nThreads: int = 0 + nBytes: int = 0 + algorithm: int = -1 + protocol: int = -1 + count: int = 0 + datatype: Any = torch.float32 + pattern: NcclPattern = NcclPattern.Ring + nstepsPerLoop: int = 1 + nchunksPerLoop: int = 1 + + +def nccl_info_set_derived(info: NcclInfo, nRanks: int): + info.nBytes = info.count * info.datatype.itemsize + if info.coll == NcclFunc.ncclFuncAllGather or info.coll == NcclFunc.ncclFuncBroadcast: + info.count = info.nBytes + info.datatype = torch.int8 + if info.coll == NcclFunc.ncclFuncAllGather or info.coll == NcclFunc.ncclFuncReduceScatter: + info.nBytes *= nRanks + return info diff --git a/vescale/emulator/nccl/init.py b/vescale/emulator/nccl/init.py new file mode 100644 index 0000000..655231a --- /dev/null +++ b/vescale/emulator/nccl/init.py @@ -0,0 +1,86 @@ +################################################################################ +# +# Copyright 2023 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ +# Some code comes from NCCL +# Original license: +# Copyright (c) 2016-2022, NVIDIA CORPORATION. All rights reserved. +# +# See LICENSE.txt for license information +################################################################################ + +from vescale.emulator.nccl.constants import * # noqa: F403 +from vescale.emulator.nccl.include.comm import NcclComm +from vescale.emulator.nccl.include.info import NcclInfo, nccl_info_set_derived + +from vescale.emulator.nccl.graph.tuning import nccl_topo_get_algo_time, nccl_topo_tune_model + + +def compute_buff_sizes(comm: NcclComm): + # envs = [ncclParamLlBuffSize(), ncclParamLl128BuffSize(), ncclParamBuffSize()] # load from env variables + envs = [-2, -2, -2] + defaults = [DEFAULT_LL_BUFFSIZE, DEFAULT_LL128_BUFFSIZE, DEFAULT_BUFFSIZE] + + for p in range(0, NCCL_NUM_PROTOCOLS, 1): + if envs[p] != -2: + comm.buff_sizes[p] = envs[p] + else: + comm.buff_sizes[p] = defaults[p] + return comm + + +def init(coll, count, dtype, nChannels, nNodes, nRanks, minCompCap, maxCompCap, graphs): + if coll == NcclFunc.ncclFuncAllReduce: + chunkSteps = ALLREDUCE_CHUNKSTEPS + sliceSteps = ALLREDUCE_SLICESTEPS + elif coll == NcclFunc.ncclFuncReduceScatter: + chunkSteps = REDUCESCATTER_CHUNKSTEPS + sliceSteps = REDUCESCATTER_SLICESTEPS + else: + raise Exception("Unsupported collective operation") + comm = NcclComm(nChannels, nNodes, nRanks, minCompCap) + info = NcclInfo(coll, comm, chunkSteps, sliceSteps, count=count, datatype=dtype) + info = nccl_info_set_derived(info, nRanks) + info.comm = compute_buff_sizes(info.comm) + info.comm = nccl_topo_tune_model(info.comm, minCompCap, maxCompCap, graphs) + + if comm.nRanks == 1: + info.algorithm = NCCL_ALGO_RING + info.protocol = NCCL_PROTO_SIMPLE + else: + info.algorithm = NCCL_ALGO_UNDEF + info.protocol = NCCL_PROTO_UNDEF + min_time = 3600000000.0 + backup_min_time = 3600000000.0 + backup = False + backupAlgo = NCCL_ALGO_UNDEF + backupProto = NCCL_PROTO_UNDEF + info.algorithm = -1 + info.protocol = -1 + nAlgos = NCCL_NUM_ALGORITHMS + for a in range(nAlgos): + for p in range(NCCL_NUM_PROTOCOLS): + time, backup = nccl_topo_get_algo_time(info, a, p, 1) + if not backup: + if time >= 0 and time < min_time: + info.algorithm = a + info.protocol = p + min_time = time + else: + if time >= 0 and time < backupMinTime: + backupAlgo = a + backupProto = p + backupMinTime = time + return info diff --git a/vescale/emulator/nccl/nccl_profiler_result.py b/vescale/emulator/nccl/nccl_profiler_result.py new file mode 100644 index 0000000..ae22624 --- /dev/null +++ b/vescale/emulator/nccl/nccl_profiler_result.py @@ -0,0 +1,74 @@ +################################################################################ +# +# Copyright 2023 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ + + +from typing import List +from vescale.emulator.nccl.include.graph import NcclTopoGraph +import xml.etree.ElementTree as ET +from vescale.emulator.nccl.constants import * # noqa: F403 + + +def get_default_min_max_compcap(): + return 80, 80 + + +def parse_graph_xml(xmlfile: str) -> List[NcclTopoGraph]: + tree = ET.parse(xmlfile) + root = tree.getroot() + + graphs = [] + for graph in root.findall("graph"): + pattern = int(graph.get("pattern")) + nChannels = int(graph.get("nchannels")) + bwIntra = float(graph.get("speedintra")) + bwInter = float(graph.get("speedinter")) + latencyInter = float(graph.get("latencyinter")) + typeIntra = graph.get("typeintra") + # Convert typeIntra string to an appropriate integer or keep as string based on your requirements + typeIntra = LINK_LOC if typeIntra == "LOC" else LINK_NVL if typeIntra == "NVL" else -1 # Example conversion + sameChannels = int(graph.get("samechannels")) + + topo_graph = NcclTopoGraph( + pattern=pattern, + nChannels=nChannels, + bwIntra=bwIntra, + bwInter=bwInter, + latencyInter=latencyInter, + typeIntra=typeIntra, + sameChannels=sameChannels, + ) + + graphs.append(topo_graph) + + return graphs + + +def parse_nccl_topo(pg): + xmlfile = pg.get_nccl_graph_xml() + + graphs = parse_graph_xml(xmlfile) + ringgraph = graphs[NCCL_ALGO_RING] + treegraph = graphs[NCCL_ALGO_TREE] + nchannels = min(ringgraph.nChannels, treegraph.nChannels) + # ncclTopoPostset + nchannels = min(MAXCHANNELS, nchannels * 2) + minCompCap, maxCompCap = get_default_min_max_compcap() + return graphs, nchannels, minCompCap, maxCompCap + + +if __name__ == "__main__": + pass diff --git a/vescale/emulator/primitives.py b/vescale/emulator/primitives.py new file mode 100644 index 0000000..a8ac99d --- /dev/null +++ b/vescale/emulator/primitives.py @@ -0,0 +1,224 @@ +################################################################################ +# +# Copyright 2023 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ + +import torch +from vescale.emulator.reduce_kernel import ReduceOp + + +class RingPrimitive: + def __init__(self, data_list, ring, reduce_op=ReduceOp.SUM, device=None, datatype=None): + self.ring = ring + self.reduce_op = reduce_op + + self.original_device = [] + self.original_datatype = [] + for data in data_list: + self.original_device.append(data.device) + self.original_datatype.append(data.dtype) + + if device is None: + device = self.original_device + elif isinstance(device, torch.device): + device = [device] * len(data_list) + else: + assert isinstance(device, list) + assert len(device) == len(data_list) + self.device = device + + if datatype is None: + datatype = self.original_datatype + elif isinstance(datatype, torch.dtype): + datatype = [datatype] * len(data_list) + else: + assert isinstance(datatype, list) + assert len(datatype) == len(data_list) + self.datatype = datatype + + self.device = device + self.data_list = self._copy_tensor_list(data_list) + self.buffer = self._init_buffer(data_list) + + def _init_buffer(self, data_list): + buffer = [] + for i, data in enumerate(data_list): + buffer.append(torch.zeros_like(data).to(self.device[i]).to(self.datatype[i])) + return buffer + + def _copy_tensor_list(self, data_list): + copy_list = [] + for i, data in enumerate(data_list): + copy_list.append(data.detach().clone().to(self.device[i]).to(self.datatype[i])) + return copy_list + + def send(self, ring_idx, offset, nelem): + next_ring_idx = self.ring.next(ring_idx) + self.buffer[next_ring_idx][offset : offset + nelem] = self.data_list[ring_idx][offset : offset + nelem] + + def recv_reduce_send(self, ring_idx, offset, nelem): + temp = self.reduce_op( + self.data_list[ring_idx][offset : offset + nelem], self.buffer[ring_idx][offset : offset + nelem] + ) + next_ring_idx = self.ring.next(ring_idx) + self.buffer[next_ring_idx][offset : offset + nelem] = temp + + def recv_reduce_copy(self, ring_idx, offset, nelem): + temp = self.reduce_op( + self.data_list[ring_idx][offset : offset + nelem], self.buffer[ring_idx][offset : offset + nelem] + ) + self.data_list[ring_idx][offset : offset + nelem] = temp + + def direct_recv_reduce_copy_send(self, ring_idx, offset, nelem): + temp = self.reduce_op( + self.data_list[ring_idx][offset : offset + nelem], self.buffer[ring_idx][offset : offset + nelem] + ) + next_ring_idx = self.ring.next(ring_idx) + self.buffer[next_ring_idx][offset : offset + nelem] = temp + self.data_list[ring_idx][offset : offset + nelem] = temp + + def direct_recv_copy_send(self, ring_idx, offset, nelem): + temp = self.buffer[ring_idx][offset : offset + nelem] + next_ring_idx = self.ring.next(ring_idx) + self.buffer[next_ring_idx][offset : offset + nelem] = temp + self.data_list[ring_idx][offset : offset + nelem] = temp + + def direct_recv(self, ring_idx, offset, nelem): + temp = self.buffer[ring_idx][offset : offset + nelem] + self.data_list[ring_idx][offset : offset + nelem] = temp + + def convert_to_original_device_and_datatype(self): + results = [] + for i, data in enumerate(self.data_list): + results.append(data.to(self.original_device[i]).to(self.original_datatype[i])) + return results + + +class TreePrimitive: + def __init__(self, data_list, tree, reduce_op, device): + self.tree = tree + self.reduce_op = reduce_op + self.device = device + + self.data_list = [self._copy_tensor_list(data_list), self._copy_tensor_list(data_list)] + self.buffer = [self._init_buffer(data_list), self._init_buffer(data_list)] + + def _init_buffer(self, data_list): + buffer = [] + for data in data_list: + buffer.append(torch.zeros_like(data).to(self.device)) + return buffer + + def _copy_tensor_list(self, data_list): + copy_list = [] + for data in data_list: + copy_list.append(data.detach().clone().to(self.device)) + return copy_list + + def send(self, rank, tree_idx, offset, nelem): + self.buffer[tree_idx][rank][offset : offset + nelem] = self.data_list[tree_idx][rank][offset : offset + nelem] + + def recv_reduce_send(self, rank, tree_idx, offset, nelem): + temp = [] + temp.append(self.data_list[tree_idx][rank][offset : offset + nelem]) + for d in self.tree.tree[tree_idx][rank].down: + if d != -1: + temp.append(self.buffer[tree_idx][d][offset : offset + nelem]) + self.buffer[tree_idx][rank][offset : offset + nelem] = self.reduce_op(temp) + + def recv_reduce_copy(self, rank, tree_idx, offset, nelem): + temp = [] + temp.append(self.data_list[tree_idx][rank][offset : offset + nelem]) + for d in self.tree.tree[tree_idx][rank].down: + if d != -1: + temp.append(self.buffer[tree_idx][d][offset : offset + nelem]) + self.data_list[tree_idx][rank][offset : offset + nelem] = self.reduce_op(temp) + + def direct_send_from_output(self, rank, tree_idx, offset, nelem): + self.buffer[tree_idx][rank][offset : offset + nelem] = self.data_list[tree_idx][rank][offset : offset + nelem] + + def direct_recv(self, rank, tree_idx, offset, nelem): + u = self.tree.tree[tree_idx][rank].up + temp = self.buffer[tree_idx][u][offset : offset + nelem] + self.data_list[tree_idx][rank][offset : offset + nelem] = temp + + def direct_recv_copy_send(self, rank, tree_idx, offset, nelem): + u = self.tree.tree[tree_idx][rank].up + temp = self.buffer[tree_idx][u][offset : offset + nelem] + self.data_list[tree_idx][rank][offset : offset + nelem] = temp + self.buffer[tree_idx][rank][offset : offset + nelem] = temp + + +class Point2PointPrimitive: + def __init__(self, data_list, ranks, device=None, datatype=None): + self.ranks = ranks + + self.original_device = [] + self.original_datatype = [] + for data in data_list: + self.original_device.append(data.device) + self.original_datatype.append(data.dtype) + + if device is None: + device = self.original_device + elif isinstance(device, torch.device): + device = [device] * len(data_list) + else: + assert isinstance(device, list) + assert len(device) == len(data_list) + self.device = device + + if datatype is None: + datatype = self.original_datatype + elif isinstance(datatype, torch.dtype): + datatype = [datatype] * len(data_list) + else: + assert isinstance(datatype, list) + assert len(datatype) == len(data_list) + self.datatype = datatype + + self.device = device + self.data_list = self._copy_tensor_list(data_list) + self.buffer = self._init_buffer(data_list) + + def _init_buffer(self, data_list): + buffer = [] + for i, data in enumerate(data_list): + buffer.append(torch.zeros_like(data).to(self.device[i]).to(self.datatype[i])) + return buffer + + def _copy_tensor_list(self, data_list): + copy_list = [] + for i, data in enumerate(data_list): + copy_list.append(data.detach().clone().to(self.device[i]).to(self.datatype[i])) + return copy_list + + def send(self, send_rank, send_offset, nelem, dtype, peer_rank, peer_offset=None): + if peer_offset is None: + peer_offset = send_offset + temp = self.data_list[send_rank][send_offset : send_offset + nelem] + self.buffer[peer_rank][peer_offset : peer_offset + nelem] = temp.to(dtype) + + def recv(self, recv_rank, recv_offset, nelem, dtype, peer_rank, peer_offset=None): + if peer_offset is None: + peer_offset = recv_offset + temp = self.buffer[recv_rank][recv_offset : recv_offset + nelem].to(dtype) + self.data_list[recv_rank][recv_offset : recv_offset + nelem] = temp + + def convert_to_original_device_and_datatype(self): + results = [] + for i, data in enumerate(self.data_list): + results.append(data.to(self.original_device[i]).to(self.original_datatype[i])) + return results diff --git a/vescale/emulator/reduce_kernel.py b/vescale/emulator/reduce_kernel.py new file mode 100644 index 0000000..4409b65 --- /dev/null +++ b/vescale/emulator/reduce_kernel.py @@ -0,0 +1,55 @@ +################################################################################ +# +# Copyright 2023 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ + +import torch +from typing import List, Union, Optional + + +class ReduceOp: + """ + ReduceOp is a class that contains all the reduce operations that can be used in the all-reduce operation. + It contains the following operations: + - SUM: Sum of all the elements in the tensor. + - MAX: Maximum of all the elements in the tensor. + - MIN: Minimum of all the elements in the tensor. + - PRODUCT: Product of all the elements in the tensor. + """ + + @staticmethod + def SUM(a: Union[torch.Tensor, List[torch.Tensor]], b: Optional[torch.Tensor] = None): + if b is not None: + return a + b + else: + return torch.sum(torch.stack(a), dim=0) + + def MAX(a: Union[torch.Tensor, List[torch.Tensor]], b: Optional[torch.Tensor] = None): + if b is not None: + return torch.max(torch.stack([a, b]), dim=0)[0] + else: + return torch.max(torch.stack(a), dim=0)[0] + + def MIN(a: Union[torch.Tensor, List[torch.Tensor]], b: Optional[torch.Tensor] = None): + if b is not None: + return torch.min(torch.stack([a, b]), dim=0)[0] + else: + return torch.min(torch.stack(a), dim=0)[0] + + def PRODUCT(a: Union[torch.Tensor, List[torch.Tensor]], b: Optional[torch.Tensor] = None): + if b is not None: + return a * b + else: + return torch.prod(torch.stack(a), dim=0) diff --git a/vescale/emulator/reduce_scatter.py b/vescale/emulator/reduce_scatter.py new file mode 100644 index 0000000..08866e4 --- /dev/null +++ b/vescale/emulator/reduce_scatter.py @@ -0,0 +1,137 @@ +################################################################################ +# +# Copyright 2023 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ +# Some code comes from reduce_scatter.cc in NCCL +# Original license: +# Copyright (c) 2016-2022, NVIDIA CORPORATION. All rights reserved. +# +# See LICENSE.txt for license information +################################################################################ + +from typing import List + +import torch +from vescale.emulator.nccl.include.info import NcclInfo +from vescale.emulator.reduce_kernel import ReduceOp +from vescale.emulator.topo import Ring +from vescale.emulator.primitives import RingPrimitive +from vescale.emulator.calculate_chunk_size import calcBytePerStep, calcBytePerGrain, compute_last_chunk_size +from vescale.emulator.nccl.constants import * # noqa: F403 + + +def contract_tensor_list(tensor_list): + """ + Contract a list of tensors into a list of tensors with the same size but with the first dimension + contracted to be the product of the sizes of the original tensors. + """ + n = len(tensor_list) + a = len(tensor_list[0]) // n + + # Create a list to hold the contracted tensors + contracted_list = [] + + for i in range(n): + # Extract the ith segment of size 'a' from the ith tensor + contracted_tensor = tensor_list[i][i * a : (i + 1) * a] + + # Add the contracted tensor to the list + contracted_list.append(contracted_tensor) + + return contracted_list + + +def run_ring_reduce_scatter( + info: NcclInfo, + nchannels: int, + nwarps: int, + protocol: int, + data_list: List[torch.Tensor], + ranks: List[int], + device: torch.device, + chunk_count: int, + channel_count: int, + grid_offset: int, + reduce_op: ReduceOp, +) -> List[torch.Tensor]: + """ + Run a ring reduce-scatter operation on the given data_list. + + Args: + info: NcclInfo object containing information about the communication. + nchannels: Number of channels in the communication. + nwarps: Number of warps in the kernel. + protocol: Protocol to use for communication. + data_list: List of tensors to be reduced and scattered. + ranks: List of ranks in the communication. + device: Device to run the operation on. + chunk_count: Size of chunks in the communication. + channel_count: Total size of elements in a rank to be sent in the communication. + grid_offset: Offset of the grid. + reduce_op: Reduction operation to perform. + + Returns: + List of tensors with the reduced and scattered data. + """ + ring = Ring(ranks) + prims = RingPrimitive(data_list, ring, reduce_op, device) + count = len(data_list[0]) // ring.nranks + + nthreads = nwarps * WARP_SIZE + + sizeof_T = data_list[0].element_size() + + chunk_count = int( + calcBytePerStep(protocol, info.comm) + / sizeof_T + * (REDUCESCATTER_CHUNKSTEPS if protocol == NCCL_PROTO_SIMPLE else 1) + ) + min_chunk_size_LL128 = int(nthreads * (calcBytePerGrain(protocol) / sizeof_T) / 2) + loop_size = nchannels * chunk_count + last_chunk_size = compute_last_chunk_size(info) + + for elem_offset in range(0, channel_count, loop_size): + if protocol == NCCL_PROTO_SIMPLE: + real_chunk_size = min(chunk_count, div_up(count - elem_offset, nchannels)) + real_chunk_size = round_up(real_chunk_size, (nthreads - WARP_SIZE) * sizeof_uint64_t / sizeof_T) + elif protocol == NCCL_PROTO_LL: + real_chunk_size = last_chunk_size if count - elem_offset < loop_size else chunk_count + elif protocol == NCCL_PROTO_LL128: + real_chunk_size = min( + div_up(count - elem_offset, nchannels * min_chunk_size_LL128) * min_chunk_size_LL128, chunk_count + ) + real_chunk_size = int(real_chunk_size) + + for bid in range(nchannels): + chunk_offset = elem_offset + bid * real_chunk_size + nelem = min(real_chunk_size, count - chunk_offset) + + for ring_idx in range(ring.nranks): + rank_dest = ring.mod_rank(ring_idx + ring.nranks - 1) + offset = chunk_offset + rank_dest * count + prims.send(ring_idx, offset, nelem) + + for j in range(2, ring.nranks, 1): + for ring_idx in range(ring.nranks): + rank_dest = ring.mod_rank(ring_idx + ring.nranks - j) + offset = chunk_offset + rank_dest * count + prims.recv_reduce_send(ring_idx, offset, nelem) + + for ring_idx in range(ring.nranks): + rank_dest = ring_idx + offset = chunk_offset + rank_dest * count + prims.recv_reduce_copy(ring_idx, offset, nelem) + + return contract_tensor_list(prims.convert_to_original_device_and_datatype()) diff --git a/vescale/emulator/topo.py b/vescale/emulator/topo.py new file mode 100644 index 0000000..97c8a2c --- /dev/null +++ b/vescale/emulator/topo.py @@ -0,0 +1,214 @@ +################################################################################ +# +# Copyright 2023 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ + +import itertools +from vescale.emulator.nccl.constants import * # noqa: F403 +from vescale.emulator.nccl.include.graph import ( + NCCL_TOPO_PATTERN_SPLIT_TREE, + NCCL_TOPO_PATTERN_TREE, +) + + +class Ring: + def __init__(self, ranks): + self.ranks = ranks + self.nranks = len(self.ranks) + + def prev(self, ring_idx): + return self.mod_rank(ring_idx + self.nranks - 1) + + def next(self, ring_idx): + return self.mod_rank(ring_idx + 1) + + def mod_rank(self, r): + if r >= self.nranks: + return r - self.nranks + else: + return r + + +def global_rank_to_group_rank(global_ranks, mapping): + result = [] + if isinstance(global_ranks, list): + for rank in global_ranks: + result.append(mapping[rank]) + else: + result = mapping[global_ranks] + return result + + +def filter_tree_structure(tree_structure, selected_ranks, mapping): + result = [] + for server in tree_structure: + filtered_server = [global_rank_to_group_rank(gpu, mapping) for gpu in server if gpu in selected_ranks] + if filtered_server: + result.append(filtered_server) + return result + + +class TreeNode: + def __init__(self, rank, up=-1, down0=-1, down1=-1, down2=-1): + self.rank = rank + self.up = up + self.down = [down0, down1, down2] + + def update(self, up=None, down0=None, down1=None, down2=None): + if up is not None: + self.up = up + if down0 is not None: + self.down[0] = down0 + if down1 is not None: + self.down[1] = down1 + if down2 is not None: + self.down[2] = down2 + + def __repr__(self): + return f"[Rank {self.rank}] up: {self.up}, down: {self.down}.\n" + + +class DoubleTree: + def __init__(self, tree_structure, ranks, mapping, pattern=NCCL_TOPO_PATTERN_SPLIT_TREE, ntrees=2): + self.device_topo = filter_tree_structure(tree_structure, ranks, mapping) + self.nranks = len(ranks) + self.pattern = pattern + + # initialize all nodes + self.tree = [] + for tree_idx in range(ntrees): + self.tree.append([]) + for i in itertools.chain.from_iterable(self.device_topo): + self.tree[tree_idx].append(TreeNode(rank=i)) + + # create intra node chains + for tree_idx in range(ntrees): + self.get_intra_node_chains(self.device_topo, tree_idx) + + # create inter node trees + self.get_double_tree(self.device_topo, 0, 1) + + def get_intra_node_chains(self, device_topo, tree_idx): + for node in range(len(device_topo)): + for i, local_rank in enumerate(device_topo[node]): + up = None + down0 = None + if i == 0: + up = None + else: + up = device_topo[node][i - 1] + if i == len(device_topo[node]) - 1: + down0 = None + else: + down0 = device_topo[node][i + 1] + self.tree[tree_idx][local_rank].update(up=up, down0=down0) + + def get_binary_tree(self, device_topo, node_mask_func=None, node_mask_reverse_func=None, tree_idx=0): + # check if device_topo is a 2D list + nnodes = len(device_topo) + nodes_list = list(range(nnodes)) + + def get_send_rank(node): + if node < 0: + return node + if self.pattern == NCCL_TOPO_PATTERN_SPLIT_TREE or self.pattern == NCCL_TOPO_PATTERN_TREE: + return device_topo[node][0] + + def get_recv_rank(node, parentChildType=0): + if node < 0: + return node + if self.pattern == NCCL_TOPO_PATTERN_SPLIT_TREE: + assert ( + len(device_topo[node]) > 1 + ), "NCCL_TOPO_PATTERN_SPLIT_TREE requires each node has at least two local ranks" + return device_topo[node][1] + if self.pattern == NCCL_TOPO_PATTERN_TREE: + return device_topo[node][0] + # if self.pattern == NCCL_TOPO_PATTERN_BALANCED_TREE: + # assert len(device_topo[node]) > 1, "NCCL_TOPO_PATTERN_BALANCED_TREE requires each node has at least two local ranks" + # return device_topo[node][parentChildType] + + for node in nodes_list: + if node_mask_func is not None: + node = node_mask_func(node) + bit = 1 + while bit < nnodes: + if bit & node: + break + bit <<= 1 + if node == 0: + u = -1 + d0 = -1 + d1 = bit >> 1 if nnodes > 1 else -1 + if d1 != -1: + if node_mask_reverse_func is not None: + node = node_mask_reverse_func(node) + d1 = node_mask_reverse_func(d1) + self.tree[tree_idx][get_recv_rank(node)].update(down2=get_send_rank(d1)) + continue + + up = (node ^ bit) | (bit << 1) + if up >= nnodes: + up = node ^ bit + parentChildType = 0 if node < up else 1 + u = up + + lowbit = bit >> 1 + down0 = -1 if lowbit == 0 else node - lowbit + down1 = -1 if lowbit == 0 else node + lowbit + while down1 >= nnodes: + down1 = -1 if lowbit == 0 else node + lowbit + lowbit >>= 1 + + if node_mask_reverse_func is not None: + node = node_mask_reverse_func(node) + u = node_mask_reverse_func(u) + down0 = node_mask_reverse_func(down0) + down1 = node_mask_reverse_func(down1) + self.tree[tree_idx][get_send_rank(node)].update(up=get_recv_rank(u)) + self.tree[tree_idx][get_recv_rank(node)].update(down1=get_send_rank(down0), down2=get_send_rank(down1)) + + def get_double_tree(self, device_topo, tree_idx_0, tree_idx_1): + nnodes = len(device_topo) + self.get_binary_tree(device_topo=device_topo, tree_idx=tree_idx_0) + + if nnodes % 2 == 1: + # shift + def node_mask_func(node): + return (node - 1 + nnodes) % nnodes + + def node_mask_reverse_func(node): + if node == -1: + return -1 + else: + return (node + 1) % nnodes + + else: + # mirror + def node_mask_func(node): + return nnodes - 1 - node + + def node_mask_reverse_func(node): + if node == -1: + return -1 + else: + return nnodes - 1 - node + + self.get_binary_tree( + device_topo=device_topo, + node_mask_func=node_mask_func, + node_mask_reverse_func=node_mask_reverse_func, + tree_idx=tree_idx_1, + ) diff --git a/vescale/emulator/utils.py b/vescale/emulator/utils.py new file mode 100644 index 0000000..eb973ef --- /dev/null +++ b/vescale/emulator/utils.py @@ -0,0 +1,79 @@ +################################################################################ +# +# Copyright 2023 ByteDance Ltd. and/or its affiliates. All rights reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +################################################################################ + +import torch +from vescale.emulator.reduce_kernel import ReduceOp + + +def flatten_tensors(tensor_list): + """ + Flatten a list of tensors into a single tensor. + """ + flattened_list = [] + original_shapes = [] + + for tensor in tensor_list: + original_shapes.append(tensor.size()) + flattened_tensor = tensor.view(-1) + flattened_list.append(flattened_tensor) + + return flattened_list, original_shapes + + +def restore_tensors(flattened_list, original_shapes): + """ + Restore a list of flattened tensors to their original shapes. + """ + restored_list = [] + + for flattened_tensor, shape in zip(flattened_list, original_shapes): + restored_tensor = flattened_tensor.view(shape) + restored_list.append(restored_tensor) + + return restored_list + + +def torch_reduce_op_to_emulator(torch_reduce_op): + """ + Convert torch reduce op to emulator reduce op. + """ + if torch_reduce_op == torch.distributed.ReduceOp.SUM: + return ReduceOp.SUM + elif torch_reduce_op == torch.distributed.ReduceOp.PRODUCT: + return ReduceOp.PRODUCT + elif torch_reduce_op == torch.distributed.ReduceOp.MIN: + return ReduceOp.MIN + elif torch_reduce_op == torch.distributed.ReduceOp.MAX: + return ReduceOp.MAX + else: + raise ValueError(f"Unsupported reduce op: {torch_reduce_op}") + + +def emulator_reduce_op_to_torch(reduce_op): + """ + Convert emulator reduce op to torch reduce op. + """ + if reduce_op == ReduceOp.SUM: + return torch.distributed.ReduceOp.SUM + elif reduce_op == ReduceOp.PRODUCT: + return torch.distributed.ReduceOp.PRODUCT + elif reduce_op == ReduceOp.MAX: + return torch.distributed.ReduceOp.MAX + elif reduce_op == ReduceOp.MIN: + return torch.distributed.ReduceOp.MIN + else: + raise ValueError(f"Unsupported reduce op: {reduce_op}")