Skip to content

Support cross-compiling onnxruntime for Windows from a Linux host#29642

Draft
Penguinwizzard wants to merge 14 commits into
microsoft:mainfrom
Penguinwizzard:penguinwizzard/mlas-amd64-dual-assembler
Draft

Support cross-compiling onnxruntime for Windows from a Linux host#29642
Penguinwizzard wants to merge 14 commits into
microsoft:mainfrom
Penguinwizzard:penguinwizzard/mlas-amd64-dual-assembler

Conversation

@Penguinwizzard

Copy link
Copy Markdown
Member

This changeset alters our MASM code to build in not only ml64 but also clang-ml,
allowing us to cross-compile for Windows targets from Linux. It also adds the related
build scripts and a GitHub action to validate that this continues to function.

Motivation and Context

Being able to cross-compile for Windows from Linux builders offers additional
flexibility in development and CI environments, which is desirable for many projects.

Penguinwizzard and others added 9 commits July 7, 2026 12:59
The MLAS x64 assembly kernels in onnxruntime/core/mlas/lib/amd64 previously
only assembled with the Microsoft Macro Assembler (ml64.exe), because they
relied on the Windows SDK's macamd64.inc and on several MASM constructs that
the LLVM MASM-compatible assembler (llvm-ml) does not accept. This blocked
cross-compiling ONNX Runtime for Windows-x64 from a non-Windows host (e.g.
building on Linux, as the Edge browser does).

This change makes all 37 amd64/*.asm files assemble under BOTH ml64.exe and
llvm-ml, and adds CI to keep them that way:

- Replace the Windows SDK macamd64.inc dependency with an ORT-owned,
  dual-compatible prologue/entry/unwind macro layer in mlasi.inc (built on
  MASM `proc frame` + SEH directives). The generated code, .pdata and .xdata
  are unchanged for ml64.
- Fix every remaining MASM-vs-llvm-ml incompatibility class, including:
  nested-struct default init (`<>` -> `{}`, which also unblocked llvm-ml's GT
  parser), label collisions from llvm-ml lacking MASM's default OPTION SCOPED
  (via LOCAL / anonymous @@ labels / token-pasted unique suffixes), the single
  `::` public-in-proc label in SconvKernelAvx.asm (guarded by IFDEF LLVM_ML),
  and an invalid VEX FMA size hint (DWORD PTR -> YMMWORD PTR) in
  SconvKernelFma3.asm. See individual files for details.
- Add .github/workflows/windows_mlas_masm_cross_assembler.yml: on a Windows
  runner it assembles every amd64/*.asm with both ml64.exe (no define) and
  llvm-ml (/DLLVM_ML=1), failing if either assembler rejects any file.

llvm-ml must be invoked with /DLLVM_ML=1 so the alternate-entry public label
uses the llvm-ml-compatible single-colon + PUBLIC form; ml64 gets no define and
is byte-for-byte unchanged. Downstream cross-compile consumers (Edge) must add
that define to their llvm-ml invocation.

Verified locally: 37/37 amd64/*.asm assemble under llvm-ml. ml64 assembly and
runtime SEH-unwind correctness are validated by the new Windows CI job.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…m-ml)

Extend the MLAS dual-assembler work into a real Linux cross-build of the whole
onnxruntime_mlas library, and move CI off windows-latest onto a Linux runner.

- cmake/win_x64_crosscompile_toolchain.cmake (new): a Windows-x64 cross toolchain
  using clang-cl + lld-link for C/C++, llvm-ml for the amd64 MASM kernels, and a
  Windows SDK/CRT staged by `xwin` (consumed via /imsvc + /libpath:). Targets
  x86_64-pc-windows-msvc, feeds llvm-ml -m64 /DLLVM_ML=1 and the amd64 include
  dir, and skips CMake's link-based compiler check (static-lib only).

- cmake/onnxruntime_mlas.cmake: add clang-gated per-file arch flags
  (AVX2/AVX512/AVXVNNI/AVX512VNNI/AMX/SSE4.1) for the x64 intrinsic kernels.
  clang-cl enforces per-function target features where MSVC does not; the block
  is gated on Clang so the MSVC/ml64 build is byte-for-byte unchanged.

- .github/workflows/linux_mlas_win_cross.yml (new): ubuntu-latest job that
  installs LLVM + xwin, cross-configures with the toolchain, builds
  onnxruntime_mlas, and asserts the library plus its amd64 .asm objects. This is
  a strict superset of the old assemble-only check and exercises the llvm-ml
  (/DLLVM_ML) branch end to end.

- Remove .github/workflows/windows_mlas_masm_cross_assembler.yml: superseded by
  the Linux cross-build. The complementary ml64.exe path (no /DLLVM_ML) is still
  covered by the native Windows build/test pipelines.

Tests are not run on Linux: the artifacts are Windows-x64 binaries and Wine is
unreliable for the AVX-512/AMX kernels; runtime correctness stays with the
native Windows pipelines.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
GitHub release assets (and their co-located .sha256 sidecars) are mutable, so a
compromised release could silently swap the xwin binary. Pin the known-good
SHA256 of the xwin tarball and verify the download against it before use.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The MSVC CRT headers staged by xwin enforce a minimum Clang version via a
static_assert (STL1000: "expected Clang 19.0.0 or newer"). LLVM 18 fails that
check; use LLVM 19.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Extend the Linux->Windows-x64 cross-compile support beyond the MLAS static
library to the entire onnxruntime.dll, using clang-cl + lld-link for C/C++,
llvm-ml for the MLAS amd64 MASM kernels, llvm-rc for the version resource, and
a Windows SDK/CRT staged by xwin. This is what downstream consumers such as the
Edge browser actually build (they compile ORT from source, not just MLAS).

Fixes the following real cross-portability issues that only surface under a
case-sensitive filesystem and/or clang's stricter front end; the native MSVC
build is unaffected:

- ONNX ScopedHandle: INVALID_HANDLE_VALUE performs a reinterpret_cast that is not
  a valid non-type template parameter under Clang. Replace the ScopedResource
  alias with a concrete class holding the sentinel at runtime (onnx.patch).
- <wil/Resource.h> / <Winver.h>: the real headers are lowercase; fix the include
  spellings so they resolve on case-sensitive filesystems.
- spin_pause.cc: clang-cl requires the waitpkg target feature for the _tpause
  intrinsic (used behind a runtime HasTPAUSE() guard); add -mwaitpkg for the
  Windows/Clang/x86_64 cross build.
- onnxruntime.rc VERSIONINFO: the octal-escaped copyright/registered glyphs in
  narrow strings are rejected by llvm-rc (no code page); use wide L"\x00A9" /
  L"\x00AE" escapes, which both rc.exe and llvm-rc accept with identical output.
  Also pass the Windows SDK include paths to llvm-rc, which does not honor /imsvc.
- MLAS QgemmU8X8KernelAvx2.asm: ExitKernel is declared LOCAL in an outer macro but
  referenced from nested macros. ml64 resolves the nested reference to the outer
  LOCAL; llvm-ml emits it as an undefined external, breaking the DLL link. Thread
  the exit label through as an explicit macro parameter so the substitution
  happens in the outer macro body (where the LOCAL is visible) under both
  assemblers.

Gate the per-target COMPILE_WARNING_AS_ERROR so an explicit
-DCMAKE_COMPILE_WARNING_AS_ERROR=OFF is honored (default stays ON). clang-cl
emits many warnings MSVC never does; treating them as errors would make the
cross-build unbuildable for no portability benefit. The native Windows pipelines
keep -WX on.

Replace the MLAS-only cross-build workflow with linux_win_cross_build.yml, which
cross-builds and link-checks the whole onnxruntime.dll on a Linux runner (LLVM 19
+ SHA-pinned xwin, dynamic MSVC runtime pinned so protobuf and ORT agree), and
verifies the output is a PE image embedding the llvm-ml-assembled MLAS kernels.
Runtime correctness and the ml64 path remain covered by the native Windows
build/test pipelines.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…copedHandle

Follow-up to the Windows-x64 Linux cross-build, addressing review findings:

- Pin and verify the SHA256 of apt.llvm.org/llvm.sh before running it as root,
  matching the existing xwin pin. The script is fetched over the network and
  executed with sudo; verifying its digest prevents a compromised or rotated
  script from running as root on the runner. Documented that the digest must be
  re-verified when the upstream script legitimately changes.
- Quote the Windows SDK/CRT include paths in CMAKE_RC_FLAGS_INIT so resource
  compilation still works if the xwin root ever resolves to a path with spaces.
- Give the ONNX ScopedHandle replacement a move constructor and move assignment
  (using release()), restoring the movability the original ScopedResource-based
  alias effectively lacked but that future ONNX code may expect. Copy remains
  deleted. Verified the patch still applies cleanly to onnx v1.22.0 and that the
  full onnxruntime.dll relinks with the updated header.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The CI full-DLL cross-build failed compiling ordinary TUs (capture.cc,
ostream_sink.cc): the xwin CRT <intrin.h> transitively includes clang's
<mmintrin.h>, and on the apt.llvm.org clang-19 snapshot the 64-bit MMX vector
typedefs (e.g. __v4hi) are only treated as vectors when 'mmx' is in the global
target features. Without it the vector_size attribute is dropped and the header
fails ("__v4hi (aka 'short')"). Enabling -mmmx globally in the cross toolchain
keeps those typedefs vectors. Cross-only (toolchain file); no effect on the
native MSVC build or ORT's own SIMD codegen (ORT uses no MMX).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…n.h)

capture.h defined `__attribute__(x)` to nothing under _MSC_VER to disable it
"for MSVC". But clang-cl also defines _MSC_VER, and clang's own intrinsic
headers rely on __attribute__((__vector_size__)) for the 64-bit MMX vector
typedefs (__v2si/__v4hi/__v8qi). Because capture.cc includes capture.h before
logging.h, the poison strips those attributes from the later
absl -> <intrin.h> -> <mmintrin.h> chain, collapsing e.g. __v2si to scalar
int and failing to compile with 20 "__v4hi (aka 'short')" errors. Real cl.exe
is unaffected (its <intrin.h> uses no __attribute__).

Narrow the poison to real MSVC only (`#if !defined(__clang__)`); clang-cl
keeps real __attribute__, which it supports. Also revert the earlier inert
`-mmmx` cross-toolchain option (no-op on the MSVC target; did not fix this).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
lld-link on a case-sensitive filesystem could not open 'PathCch.lib' at the
final onnxruntime.dll link: xwin splats case-variant symlinks (pathcch.lib,
PATHCCH.lib) but not the mixed-case "PathCch.lib" that path_lib.cc requested via
`#pragma comment(lib, "PathCch.lib")`. Native link.exe is case-insensitive, so
lowercase "pathcch.lib" resolves on both Windows and the cross toolchain.

Lowercase both PathCch.lib pragmas in path_lib.cc, and proactively lowercase
Dbghelp.lib -> dbghelp.lib in debug_alloc.cc (a debug-only TU with the same
latent hazard).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

@Penguinwizzard Penguinwizzard changed the title Penguinwizzard/mlas amd64 dual assembler Support cross-compiling onnxruntime for Windows from a Linux host Jul 9, 2026
@Penguinwizzard Penguinwizzard marked this pull request as draft July 9, 2026 21:38
Penguinwizzard and others added 5 commits July 9, 2026 16:24
The dual-assembler port added multi-label LOCAL directives to macros invoked
multiple times, because llvm-ml lacks ml64's default OPTION SCOPED per-PROC label
scoping and would otherwise see duplicate global labels. Three of those LOCAL
logical lines exceed ml64's ~512-char line limit, breaking the native Windows
(MASM) build with A2039 plus a cascade of A2070 errors:
  QgemmU8S8KernelAvx2.asm  MlasGemmCopyPackAAvx2 / MlasGemmCopyPackBAvx2
  ConvSymKernelAvx2.asm    ConvSymKernelFunction

Replace LOCAL with token-paste unique-label suffixes (matching the port's
existing ExitKernel&KernelType& pattern). A per-macro literal discriminator plus
the macro parameter guarantees globally-unique labels across every invocation:
  MlasGemmCopyPackAAvx2(ASigned)          -> <label>A&ASigned&
  MlasGemmCopyPackBAvx2(IsVnni,BSigned)   -> <label>B&IsVnni&&BSigned&
  ConvSymKernelFunction(Isa)              -> <label>K&Isa&
  ConvSymDepthwiseKernelFunction(Isa)     -> <label>D&Isa&

This removes the long lines entirely (no LOCAL directive) so ml64 accepts the
files, while keeping labels unique for llvm-ml. Only label names change; every
jump/branch target still resolves, so there is no behavioral change. Verified
under llvm-ml both with and without -DLLVM_ML; the no-define mode makes all
labels global and still resolves, a strict superset of ml64's per-PROC scoping.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The previous commit removed the over-long LOCAL directives but the generated
token-paste labels kept a lone trailing '&' after the final substituted macro
parameter (e.g. ProcessRemainingRowsA&ASigned& , ExitRoutineA&ASigned&:). ml64
rejected every macro invocation with A2070 (invalid instruction operands): a
trailing '&' at a token boundary is not the MASM idiom and appears to make ml64
merge the label with the following source line.

Upstream ml64-proven token-paste (origin/main SconvKernelAvx.asm:152,261) uses
'&' only to separate a parameter from adjacent identifier characters, never a
trailing '&' before ':' or end-of-line. Regenerate the four affected macros to
match that idiom:

  MlasGemmCopyPackAAvx2(ASigned)          -> <label>A&ASigned
  MlasGemmCopyPackBAvx2(IsVnni,BSigned)   -> <label>B&IsVnni&&BSigned
  ConvSymKernelFunction(Isa)              -> <label>K&Isa
  ConvSymDepthwiseKernelFunction(Isa)     -> <label>D&Isa

Labels remain globally unique (ExitRoutineA0/A1, ExitRoutineB0/B10/B11,
ExitKernelKAvx2/KAvxVnni/DAvx2/DAvxVnni). Verified under llvm-ml with and
without -DLLVM_ML. Label-only renames; every branch target still resolves, so
there is no behavioral change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The token-paste unique-label approach (commits 42e071c, be65f5e) kept
failing native ml64 with A2070 "invalid instruction operands" at every
macro-invocation site, for a reason not reproducible with any local
MASM-compatible assembler (llvm-ml and UASM both accept it).

Pivot to the only mechanism proven in BOTH ml64 and llvm-ml: a single
first-statement LOCAL directive. LOCAL was already the port's original
approach; its sole ml64 failure was A2039 (line too long) on three
over-long label lists, which then cascaded into A2070s. Rather than
work around the length with token-paste, shorten the LOCAL label names
so each logical line fits ml64's ~512-char limit:

  MlasGemmCopyPackAAvx2  LOCAL 564 -> 186 chars
  MlasGemmCopyPackBAvx2  LOCAL 615 -> 195 chars
  ConvSymKernelFunction  LOCAL 662 -> 240 chars

Labels are macro-local scratch names; renaming defs + branch targets is
purely cosmetic (no jump target or behavior changes). LOCAL scopes each
label per-expansion in both assemblers, so the two invocations of each
macro no longer collide under llvm-ml (which treats plain labels as
global) while ml64 keeps its per-proc scoping.

Verified: each file assembles cleanly under llvm-ml with AND without
-DLLVM_ML (no-define makes all labels global, a strict superset of
ml64's requirement, proving global uniqueness + full ref resolution).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The dual-assembler port changed local-frame struct fields from OWORD to
QWORD 2 DUP(?) because llvm-ml rejects OWORD/XMMWORD as struct field
types. That change is byte-size-identical (16 bytes) but alters the
field's DECLARED element type from 16-byte to 8-byte (QWORD). ml64
infers a memory operand's size from the field type, so the unqualified
16-byte xmm save/restore instructions

    movaps  Offset[rsp], Reg              (save_xmm128 macro)
    movaps  xmm6, Frame.SavedXmm6[rsp]    (inline restores)

became "movaps <xmm>, <8-byte mem>" under ml64 -> A2070 "invalid
instruction operands". (llvm-ml is lenient here and infers 16 bytes from
the xmm register, which is why the Linux cross-build stayed green and the
bug was masked on native Windows by an earlier A2039 abort.)

Fix: add an explicit XMMWORD PTR size override at every 16-byte frame
access, so both assemblers use 16 bytes regardless of the field's
declared type. Centralized the save path in the shared save_xmm128 macro
(mlasi.inc, covers all files) and added XMMWORD PTR to the 123 inline
movaps restore sites across the amd64 kernels. movaps of an xmm register
is always 16-byte, so the emitted bytes are unchanged; this is purely a
size-annotation fix for ml64's stricter operand-size checking.

Verified: all 37 amd64/*.asm assemble under llvm-ml with -DLLVM_ML=1
(system llvm-ml-19 and the Chromium-bundled llvm-ml).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…(fix A2009)

The dual-assembler port rewrote AVX-512 embedded-broadcast operands from
MASM's 'DWORD/QWORD BCST [mem]' keyword (which llvm-ml does not accept) to
the NASM-style 'DWORD/QWORD PTR [mem]{1toN}' decorator. ml64 rejects the
{1toN} decorator -> A2009 "syntax error in expression". This was masked on
native Windows until the earlier A2039/A2070 failures were fixed and ml64
reached these AVX-512 kernels (QgemmU8X8KernelAvx512Core.asm(702)).

Neither broadcast form is accepted by both assemblers, so select the right
one at assembly time via LLVM_ML (defined only for the llvm-ml build). Added
shared text equates in mlasi.inc:

  IFDEF LLVM_ML : MlasBcstD=<DWORD PTR>  MlasBcstDSuffix=<{1to16}> ...
  ELSE          : MlasBcstD=<DWORD BCST> MlasBcstDSuffix=<>        ...

A broadcast operand is written  MlasBcstD [mem]MlasBcstDSuffix  and expands
to 'DWORD PTR [mem]{1to16}' for llvm-ml or 'DWORD BCST [mem]' for ml64 -- the
latter being byte-identical to origin/main's proven ml64 text. Applied to:
- QgemmU8X8KernelAvx512Core.asm (36 operands) and SconvKernelAvx512F.asm (6),
  which used the raw inline {1to16} form;
- SgemmKernelCommon.inc / DgemmKernelCommon.inc, whose FgemmElementBcst /
  FgemmElementBcstSuffix text equates (consumed by FgemmKernelAvx512FCommon.inc)
  are now conditional (DWORD/QWORD BCST + empty suffix under ml64).

Verified: all 37 amd64/*.asm assemble under llvm-ml -DLLVM_ML=1 and the
emitted broadcast encodings are correct (vpmulld (%r11){1to16},...). The ml64
branch reproduces main's exact BCST text; empty-suffix TEXTEQU expansion
adjacent to ']' validated with a MASM-compatible assembler.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant