@@ -403,6 +403,22 @@ std::string emit_ninja_string(const BuildPlan& plan) {
403403 // dyndep is a precondition: without it nothing declares BMIs as outputs, so
404404 // there is no BMI edge for importers to depend on.
405405 const bool splitBmi = plan.scheduleTag == " detach-codegen" && dyndep;
406+ // The other split shape. Clang publishes no BMI early — it writes the BMI
407+ // at the end of the compile — so the GCC trick of releasing the file
408+ // mid-compile has nothing to release. What clang has instead is a driver
409+ // mode that stops once the BMI exists, so the split is two INDEPENDENT
410+ // PROCESSES over the same source: one emits the BMI (fast; importers wait
411+ // only on this), one emits the object (slow; nobody waits on it).
412+ //
413+ // The object edge recompiles the source rather than reading the BMI back.
414+ // That is not a missed shortcut — see BmiTraits::bmiOnlyFlags: the BMI that
415+ // CAN be read back is clang's *full* BMI, which is ~2x larger and makes
416+ // clang 22.1.8 miscompile a downstream TU on mcpp's own graph. MEASURED on
417+ // src/build/prepare.cppm: BMI edge 1.67s, object edge 7.31s, against 7.35s
418+ // for the single edge — so ~22% more CPU buys a 4.4x shorter critical path,
419+ // and both outputs are byte-identical to the single-edge build's.
420+ const bool twoPhase = plan.scheduleTag == " two-phase" && dyndep
421+ && !traits.bmiOnlyFlags .empty ();
406422 const auto & dial = mcpp::toolchain::dialect_for (plan.toolchain );
407423 std::string out;
408424 auto append = [&](std::string s) { out += std::move (s); };
@@ -503,9 +519,14 @@ std::string emit_ninja_string(const BuildPlan& plan) {
503519 append (" restat = 1\n\n " );
504520
505521 // P1: per-file dyndep rule. Converts one .ddi → .dd independently.
522+ //
523+ // `$bind` is per-edge, not per-graph: under two-phase only the units that
524+ // are actually SPLIT bind their record to the BMI. An implementation unit
525+ // or a plain .cpp still compiles in one edge whose output is the object,
526+ // and a `--target-bmi` there would name an edge nobody declared.
506527 append (std::format (
507528 " rule cxx_dyndep\n "
508- " command = $mcpp dyndep --single --bmi-dir {} --bmi-ext {} $expect --output $out $in\n "
529+ " command = $mcpp dyndep --single --bmi-dir {} --bmi-ext {} $bind $ expect --output $out $in\n "
509530 " description = DYNDEP $out\n "
510531 " restat = 1\n\n " ,
511532 traits.bmiDir , traits.bmiExt ));
@@ -751,6 +772,57 @@ std::string emit_ninja_string(const BuildPlan& plan) {
751772 append (" restat = 1\n\n " );
752773 }
753774
775+ if (twoPhase) {
776+ // Edge A — the BMI, and nothing else. `$out` is the BMI here.
777+ append (" rule cxx_precompile\n " );
778+ if constexpr (mcpp::platform::is_windows) {
779+ const std::string payload = " $local_includes" ;
780+ append (std::format (
781+ " command = $cxx{} $cxxflags $unit_cxxflags{}{} $in {}$out\n " ,
782+ rsp_ref (payload), traits.bmiOnlyFlags , module_src_flags,
783+ dial.outputObjPrefix ));
784+ append_rspfile (payload);
785+ append_deps ();
786+ } else {
787+ // Same bak / bmi-equal / restore dance as cxx_module, and for the
788+ // same reason: ninja's `restat` compares the output's MTIME, and a
789+ // compiler that rewrites a byte-identical BMI still moves it. What
790+ // suppresses the cascade is putting the old file back.
791+ append (std::format (
792+ " command = "
793+ " if [ -f \" $out\" ]; then cp -p \" $out\" \" $out.bak\" ; fi && "
794+ " $cxx $local_includes $cxxflags $unit_cxxflags{}{} {}$in {}$out && "
795+ " if [ -f \" $out.bak\" ] && $mcpp bmi-equal \" $out\" \" $out.bak\" ; then "
796+ " mv \" $out.bak\" \" $out\" ; "
797+ " else rm -f \" $out.bak\" ; fi\n " ,
798+ traits.bmiOnlyFlags , module_src_flags, mmd_flag,
799+ dial.outputObjPrefix ));
800+ append_cxx_deps ();
801+ }
802+ append (" description = BMI $out\n " );
803+ append (" restat = 1\n\n " );
804+
805+ // Edge B — the object, compiled from the SAME SOURCE, with no
806+ // `-fmodule-output`: the BMI is edge A's output and two edges must not
807+ // write one file. Identical to cxx_object except for the language flag
808+ // that tells the driver this source is a module interface.
809+ append (" rule cxx_module_object\n " );
810+ if constexpr (mcpp::platform::is_windows) {
811+ const std::string payload = " $local_includes" ;
812+ append (std::format (" command = $cxx{} $cxxflags $unit_cxxflags{} {}\n " ,
813+ rsp_ref (payload), module_src_flags, compile_tail));
814+ append_rspfile (payload);
815+ append_deps ();
816+ } else {
817+ append (std::format (
818+ " command = $cxx $local_includes $cxxflags $unit_cxxflags{} {}{}{}\n " ,
819+ module_src_flags, mmd_flag, compile_tail, mmd_filter));
820+ append_cxx_deps ();
821+ }
822+ append (" description = OBJ $out\n " );
823+ append (" restat = 1\n\n " );
824+ }
825+
754826 append (" rule cxx_object\n " );
755827 if constexpr (mcpp::platform::is_windows) {
756828 const std::string payload = " $local_includes" ;
@@ -1279,10 +1351,27 @@ std::string emit_ninja_string(const BuildPlan& plan) {
12791351 if (exp.empty ()) exp = " --expect-none" ;
12801352 ddi_expect[ddi] = std::move (exp);
12811353 }
1354+ // Which units get the split shape. Computed ONCE and consulted from
1355+ // both loops below: the dyndep record and the edge it augments have to
1356+ // agree on the target, and when they disagree ninja blames the edge
1357+ // ("'…pcm' not mentioned in its dyndep file") rather than the record.
1358+ std::set<std::string> two_phase_ddi;
1359+ if (twoPhase) {
1360+ for (auto & cu : plan.compileUnits ) {
1361+ if (cu.servedFromCache ) continue ;
1362+ if (is_scan_exempt (cu)) continue ;
1363+ if (!cu.providesModule ) continue ;
1364+ if (cu.kind != mcpp::SourceKind::ModuleInterface) continue ;
1365+ two_phase_ddi.insert (
1366+ (cu.object .parent_path () / cu.source .filename ()).string () + " .ddi" );
1367+ }
1368+ }
12821369 for (auto & ddi : ddi_paths) {
12831370 auto dd = ddi + " .dd" ; // e.g. obj/cli.cppm.ddi.dd
12841371 ddi_to_dd[ddi] = dd;
12851372 append (std::format (" build {} : cxx_dyndep {}\n " , dd, ddi));
1373+ if (two_phase_ddi.contains (ddi))
1374+ append (" bind = --split-module\n " );
12861375 if (auto it = ddi_expect.find (ddi); it != ddi_expect.end ())
12871376 append (std::format (" expect = {}\n " , it->second ));
12881377 }
@@ -1331,6 +1420,38 @@ std::string emit_ninja_string(const BuildPlan& plan) {
13311420 // shape rather than emitting a BMI edge nothing can order.
13321421 }
13331422
1423+ if (twoPhase && cu.providesModule &&
1424+ cu.kind == mcpp::SourceKind::ModuleInterface) {
1425+ const auto bmi = bmi_path (*cu.providesModule );
1426+ const auto obj = escape_ninja_path (cu.object );
1427+ const auto ddi = (cu.object .parent_path () / cu.source .filename ())
1428+ .string () + " .ddi" ;
1429+ auto it = ddi_to_dd.find (ddi);
1430+ if (it != ddi_to_dd.end ()) {
1431+ // Both edges read the same source and so need the same
1432+ // imported BMIs; `--split-module` made the .dd carry a
1433+ // record for each. They are otherwise INDEPENDENT — the
1434+ // object edge does not wait for the BMI edge, which is what
1435+ // lets codegen drift behind the front of the graph.
1436+ auto edge = [&](std::string_view rule, const std::string& out) {
1437+ std::string e = std::format (" build {} : {} {} | {}" ,
1438+ out, rule,
1439+ escape_ninja_path (cu.source ),
1440+ it->second );
1441+ e += stagedOrderOnly;
1442+ e += " \n dyndep = " + it->second + " \n " ;
1443+ if (auto inc = local_include_flags (cu, dial); !inc.empty ())
1444+ e += " local_includes =" + inc + " \n " ;
1445+ if (auto fl = join_flags (cu.packageCxxflags ); !fl.empty ())
1446+ e += " unit_cxxflags =" + fl + " \n " ;
1447+ append (std::move (e));
1448+ };
1449+ edge (" cxx_precompile" , bmi);
1450+ edge (" cxx_module_object" , obj);
1451+ continue ;
1452+ }
1453+ }
1454+
13341455 std::string out_line = " build " + escape_ninja_path (cu.object );
13351456 if (cu.providesModule ) {
13361457 out_line += " | " + bmi_path (*cu.providesModule );
0 commit comments