Teams tuning a Pyodide inference path tend to treat compiler flags like a speed dial: turn -O3 all the way up, switch on SIMD, enable aggressive inlining, and expect inference to get meaningfully faster. It rarely works that cleanly. Compiler flags on a WebAssembly toolchain change three distinct things — code size, compile-time cost, and execution behaviour — and only one of those maps to inference latency at all. Worse, the flags that would help most are constrained by the browser sandbox and the runtime, and none of them recover the native CUDA path you left behind when you chose WASM. The useful mental model is simpler than the flag documentation suggests: flags move the memory-bound and glue-code overhead, but for a compute-bound model running inside CPython-on-WASM, they rarely touch the interpreter-plus-WASM bottleneck. If your model is spending its time in a Python-level loop or in a numeric kernel that Emscripten couldn’t vectorise, no optimization level will save it. This article breaks the flag space into what each class actually affects, so you can measure the per-flag delta against fixed latency, footprint, and cold-start targets instead of guessing. What do compiler flags mean in a WASM inference build? A compiler flag is an instruction to the toolchain — emcc (Emscripten), clang, or an LLVM-backed WASM compiler — about how to translate source into a .wasm module. For a Pyodide inference path, the flags that matter fall into a small number of classes, and each class touches a different part of the system. Optimization levels (-O0 through -O3, plus -Os and -Oz) control how aggressively the compiler rewrites your code. Higher levels inline functions, unroll loops, and eliminate dead code — at the cost of longer compile time and, often, a larger module. SIMD flags (-msimd128) enable WebAssembly’s 128-bit fixed-width vector instructions, which a browser only executes if it and its sandbox support them. Threading flags (-pthread, plus SHARED_MEMORY and the cross-origin isolation headers the browser demands) unlock Web Workers backed by SharedArrayBuffer. Link-time and size flags (-flto, -Oz, closure compiler passes) trade build complexity for a smaller bundle. The naive framing treats all of these as “make it faster.” The accurate framing is that they operate on different bottlenecks, and most inference paths are dominated by exactly one bottleneck at a time. Naming that bottleneck before you touch a flag is the entire game. This is the same discipline we describe for native ports in what -O3, -march, and fast-math actually change on a ported inference path — the WASM sandbox just adds more constraints on which flags are even available. Which flags affect latency versus only code size or compile time? This is the distinction that saves the most wasted effort. A flag can change the module, change the build, or change execution — and only the last one moves per-inference latency. Flag class Primary effect Latency impact Footprint impact Cold-start impact -O2 / -O3 Rewrites code; inlines, unrolls Real only on hot numeric kernels the compiler can actually optimise Usually grows module Larger module = slower fetch/compile -Os / -Oz Optimises for size Neutral to slightly negative Shrinks module Faster cold-start on constrained links -msimd128 (SIMD) 128-bit vector ops Real on vectorisable kernels, only if browser supports it Minor Negligible -pthread (threads) Multi-worker parallelism Real on parallelisable stages, only under cross-origin isolation Adds worker glue Adds a worker-spawn cost -flto (LTO) Cross-module optimisation Small, indirect Can shrink module Longer build, sometimes faster load Closure / minification Shrinks JS glue None on WASM execution Shrinks JS bundle Faster JS parse Read the table honestly: most of the columns marked “real” carry a qualifier. -O3 helps a hot numeric kernel that the compiler can vectorise and inline — but a lot of what runs in a Pyodide path is the CPython interpreter dispatching bytecode, and the interpreter loop is already compiled the way its authors intended. Cranking your own optimization level does nothing to it. As an observed pattern across the porting engagements we’ve worked on, the biggest surprise for teams is that -O3 often grows the module, which slows cold-start — so the flag they reached for to make things faster made the first-load experience worse (observed across TechnoLynx engagements; not a published benchmark). The one reliable win is on the glue and memory-bound edges: array marshalling between JavaScript and WASM, buffer copies, and simple element-wise passes. Those respond to optimization and SIMD flags. The compute-bound core usually does not. What do SIMD and threading flags enable — and where are they constrained? SIMD and threads are the two flags people expect the most from, and they are also the two most tightly bound to the browser sandbox. -msimd128 compiles your vectorisable loops to WebAssembly SIMD instructions. When the loop is genuinely data-parallel — an element-wise activation, a normalisation pass, a dot-product kernel that the compiler recognises — this can meaningfully reduce that stage’s time. The catch is threefold: the browser must support WASM SIMD (modern Chromium and Firefox do; older or locked-down environments may not), the compiler must actually recognise the loop as vectorisable, and the win only shows up if that loop was on your critical path to begin with. SIMD applied to code that isn’t hot is measurement noise. The portability lesson here mirrors the CPU story we cover in what SIMD width buys a ported inference path — width helps only where the kernel is both hot and vectorisable. Threading is more constrained still. -pthread in Emscripten maps to Web Workers over SharedArrayBuffer, which the browser will not hand you unless the page is served with Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers set for cross-origin isolation. If you don’t control those headers — common on shared hosting, CDNs, or embedded contexts — threading is simply off the table regardless of your flags. Even when it works, spawning workers adds a cold-start cost, and the parallel speedup only materialises on stages that decompose cleanly. A single-threaded interpreter loop won’t parallelise because you passed -pthread. The honest summary: SIMD and threads unlock parallel primitives only where the runtime and the sandbox permit, and only where the workload was parallelisable in the first place. They are real levers with narrow applicability, not a general speed dial. Why do optimization flags fail to move a compute-bound CPython-on-WASM bottleneck? Here is the mechanism most teams miss. When you run a model through Pyodide, you are running CPython — compiled to WASM — interpreting Python bytecode, which in turn calls numeric routines that may or may not have been vectorised. The -O3 you pass when building your own extension applies to your code. It does not re-optimise the CPython interpreter, and it does not change how many bytecode dispatches your Python-level inference loop performs. If your model’s time is dominated by Python-level control flow — a loop over tokens, a per-sample Python function, a framework’s eager dispatch — then the bottleneck is interpreter overhead, and interpreter overhead is a property of running CPython-on-WASM, not of your flags. This is why a naive -O3 pass so often produces a delta indistinguishable from noise on the exact workload the team cared about. It’s also why the answer is frequently not another flag but a structural change: move the hot loop into a compiled kernel, reduce the number of Python-boundary crossings, or reconsider whether the Pyodide path fits the workload at all. Understanding where the cost actually lives is the whole point of profiling the Python inference path before a C++ or WASM port — the profile tells you whether flags can even reach your bottleneck. None of this recovers native execution. WASM inside a browser sandbox has no path to CUDA, and the compute-bound ceiling of CPython-on-WASM sits well below a tuned native runtime. Flags narrow the gap on the edges; they do not close it. How do you measure the per-flag delta against a fixed target? The only defensible way to tune WASM inference flags is to hold three targets fixed and measure each flag’s effect against all three at once. Turning a flag on and eyeballing “feels faster” is how teams end up shipping a larger module that loads slower to save a millisecond of execution nobody notices. Use this rubric per flag change: Fix the baseline. Record per-inference latency, .wasm module size (and total bundle including JS glue), and cold-start time (fetch + compile + instantiate) on the target browser and connection profile. This is the same profiling baseline the Pyodide fit assessment establishes — flag tuning refines it, it doesn’t replace it. Change one flag at a time. Never bundle -O3, SIMD, and threading in one build and try to attribute the result. You won’t know which flag moved which number. Record the three deltas. Latency delta (median and p95 under realistic input), module/bundle-size delta, cold-start delta. A flag that improves latency by a hair while adding 30% to cold-start is a regression for most browser-delivered inference. Classify the delta. Did the flag move the bottleneck, or only move compile-time cost and code size? A flag that changes build time and module size but leaves your critical path untouched is administrative overhead, not a performance win. Check the release gate. Any flag that changes module size or cold-start touches a release-readiness gate. Re-validate bundle-size budgets and first-load timing after every flag change; these are governed by the AI infrastructure release-readiness discipline, not by the inference build in isolation. The output of this loop is a documented per-flag record: which flags delivered a real latency improvement, which only changed footprint, and which merely cost build time. That record is what turns “which flags make our WASM inference faster?” from a guess into an answer. If you want a structured artifact for capturing it alongside the rest of the port decision, our Inference Cost-Cut Pack scopes exactly this compiler-flag tuning within the Pyodide/WASM branch of the port decision. The broader engineering context lives on our GPU and accelerated inference practice page. When do flags justify a Pyodide path — and when do they just move the cost? The decision this article really serves is not “which flag” but “does flag tuning make Pyodide viable for this workload.” The answer depends on where your time goes. If your inference path is memory-bound or dominated by JavaScript-WASM glue — lots of buffer marshalling, element-wise passes, modest numeric kernels — then SIMD and optimization flags can move it into an acceptable envelope, and cold-start can be controlled with size flags. That’s a case where flags help enough to justify the path. If your path is compute-bound inside the CPython interpreter, flags will move compile-time cost and module size without touching latency, and the honest recommendation is a structural change or a different target, not more flags. The comparison of what optimization flags do across build targets is worth reading in parallel: GCC compiler flags that matter for inference and WASM builds covers the native-and-WASM overlap this article deliberately narrows to the Pyodide sandbox. There’s also an interaction that surprises teams: bundle-size and cold-start trade-offs interact with wheel loading. Pyodide fetches and instantiates package wheels at startup, so aggressive optimization that grows your own module compounds with wheel-download time on the cold path. A flag that helps steady-state latency can hurt the first-inference experience badly enough to fail a release gate. Measure both. FAQ How does compiler flags work? A compiler flag tells the WASM toolchain (Emscripten, clang, or an LLVM-backed compiler) how to translate source into a .wasm module. In practice, the flags for a Pyodide inference build split into optimization levels, SIMD, threading, and size/link flags — and each class changes a different thing: execution behaviour, code size, or compile-time cost. Only the flags that change execution behaviour on your hot path move inference latency. Which WASM/Pyodide compiler flags actually affect inference latency versus only code size or compile time? Optimization levels (-O2/-O3) and SIMD (-msimd128) can affect latency, but only on vectorisable, hot numeric kernels the compiler can actually optimise. Size flags (-Os/-Oz), LTO, and minification mostly change module size and compile time, not per-inference latency. -O3 frequently grows the module, which slows cold-start — so a flag reached for to gain speed can make first load worse. What do SIMD and threading flags enable for ML inference in a browser sandbox, and where are they constrained? SIMD (-msimd128) compiles vectorisable loops to 128-bit WASM vector instructions, but only if the browser supports WASM SIMD, the compiler recognises the loop, and that loop is on your critical path. Threading (-pthread) uses Web Workers over SharedArrayBuffer, which the browser refuses unless the page is served with cross-origin isolation headers. Both unlock parallel primitives only where the runtime and sandbox permit and only where the workload was parallelisable to begin with. Why do optimization flags like -O3 often fail to move a compute-bound CPython-on-WASM bottleneck? Your -O3 applies to your own code, not to the CPython interpreter compiled into Pyodide. If inference time is dominated by Python-level bytecode dispatch — a token loop, per-sample Python functions, eager framework dispatch — the bottleneck is interpreter overhead, which flags do not touch. The fix is structural (move the hot loop into a compiled kernel, cut Python-boundary crossings) rather than another flag. How do we measure the per-flag delta on latency, module size, and cold-start against a fixed target? Fix a baseline for per-inference latency, module/bundle size, and cold-start time on the target browser and connection. Change one flag at a time, record all three deltas, and classify whether the flag moved the bottleneck or only moved compile-time cost and code size. Re-validate release gates (bundle budget, first-load timing) after every change, since size and cold-start deltas are release-readiness concerns. When do compiler flags help enough to justify a Pyodide path, and when do they just move the cost? Flags justify the path when your workload is memory-bound or dominated by JS-WASM glue — SIMD and optimization move it into an acceptable envelope and size flags control cold-start. They only move the cost when the path is compute-bound inside the interpreter, where flags change build time and module size without touching latency. In that case the honest answer is a structural change or a different target. What flag trade-offs affect bundle size and cold-start, and how do they interact with wheel loading? Optimization levels that grow the module and threading glue that adds worker code both increase what the browser must fetch, compile, and instantiate before the first inference. Pyodide also downloads and instantiates package wheels at startup, so a larger module compounds with wheel-download time on the cold path. A flag that improves steady-state latency can degrade first-inference cold-start badly enough to fail a release gate — so measure both, not just the warm number. The question that decides the flag budget The right closing question for a WASM inference tuning effort isn’t “how high can I set the optimization level” — it’s a precision-and-target question: which flag actually moved the bottleneck against a fixed latency, footprint, and cold-start target, and which only moved compile-time cost? Answer that with a one-flag-at-a-time measurement record and the compiler-flag conversation stops being a speed-dial fantasy and becomes a documented trade-off you can defend at a release gate. The failure class to watch for is the module that got “optimised” into a slower cold-start; the profiling baseline in the Inference Cost-Cut Pack exists to catch exactly that before it ships.