diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
 # Changelog for `mlkem`
 
+## 0.2.3.0 - 2026-08-16
+
+* Improve performance of modular reduction with the NCG
+
+* Use `finally` instead of masking asynchronous exceptions
+
+* Refresh the test vectors
+
 ## 0.2.2.0 - 2026-06-28
 
 * Clearing buffers containing secrets is more resilient and takes into account
diff --git a/mlkem.cabal b/mlkem.cabal
--- a/mlkem.cabal
+++ b/mlkem.cabal
@@ -1,15 +1,15 @@
 cabal-version: 2.2
 
--- This file has been generated from package.yaml by hpack version 0.39.1.
+-- This file has been generated from package.yaml by hpack version 0.39.6.
 --
 -- see: https://github.com/sol/hpack
 
 name:           mlkem
-version:        0.2.2.0
+version:        0.2.3.0
 synopsis:       Module-Lattice-based Key-Encapsulation Mechanism
 description:    Module-Lattice-based Key-Encapsulation Mechanism (ML-KEM) implemented in
                 Haskell.
-category:       Crypto
+category:       Cryptography
 homepage:       https://codeberg.org/ocheron/hs-mlkem#readme
 bug-reports:    https://codeberg.org/ocheron/hs-mlkem/issues
 author:         Olivier Chéron
@@ -44,6 +44,7 @@
       Builder
       ByteArrayST
       Crypto
+      Endian
       Equality
       Fusion
       Internal
@@ -97,6 +98,7 @@
     , bytestring
     , deepseq
     , directory
+    , filelock
     , mlkem
     , primitive >=0.7.2
     , process
@@ -127,6 +129,7 @@
       ByteArrayST
       Crypto
       Crypto.PubKey.ML_KEM
+      Endian
       Equality
       Fusion
       Internal
@@ -158,6 +161,7 @@
     , bytestring
     , deepseq
     , directory
+    , filelock
     , primitive >=0.7.2
     , process
     , tasty
diff --git a/src/Auxiliary.hs b/src/Auxiliary.hs
--- a/src/Auxiliary.hs
+++ b/src/Auxiliary.hs
@@ -38,7 +38,9 @@
 import Control.Monad.ST
 
 import Data.Bits
+#ifdef __GLASGOW_HASKELL_LLVM__
 import Data.Int
+#endif
 import Data.Proxy
 import Data.Word
 
@@ -91,17 +93,40 @@
     (b `unsafeShiftR` 1 .&. 1) `unsafeShiftL` 5 .|.
     (b .&. 1) `unsafeShiftL` 6
 
-unsafeShiftIR :: Word16 -> Int -> Word16
-unsafeShiftIR x s = fromIntegral ((fromIntegral x :: Int16) `unsafeShiftR` s)
-{-# INLINE unsafeShiftIR #-}
-
 -- Reduction 𝑥 mod 𝑞 for 0 ≤ 𝑥 < 2𝑞
 reduceSimple :: Word16 -> Word16
-reduceSimple x = (mask .&. x) .|. (complement mask .&. subtracted)
+#ifdef __GLASGOW_HASKELL_LLVM__
+reduceSimple = reduceSimpleLLVM
+#else
+reduceSimple = fromIntegral . reduceSimpleNCG . fromIntegral
+#endif
+{-# INLINE reduceSimple #-}
+
+#ifdef __GLASGOW_HASKELL_LLVM__
+-- LLVM operates more efficiently on Word16 directly
+reduceSimpleLLVM :: Word16 -> Word16
+reduceSimpleLLVM x = (mask .&. x) .|. (complement mask .&. subtracted)
   where
     subtracted = x - q16
     mask = subtracted `unsafeShiftIR` 15
-{-# INLINE reduceSimple #-}
+
+    unsafeShiftIR :: Word16 -> Int -> Word16
+    unsafeShiftIR w s = fromIntegral ((fromIntegral w :: Int16) `unsafeShiftR` s)
+#else
+-- NCG code performance is better with full machine words, as this removes
+-- conversion instructions at several intermediate steps
+reduceSimpleNCG :: Word -> Word
+reduceSimpleNCG x = (mask .&. x) .|. (complement mask .&. subtracted)
+  where
+    subtracted = x - qW
+    mask = subtracted `unsafeShiftIR` (finiteBitSize qW - 1)
+
+    unsafeShiftIR :: Word -> Int -> Word
+    unsafeShiftIR w s = fromIntegral ((fromIntegral w :: Int) `unsafeShiftR` s)
+
+    qW :: Word
+    qW = fromInteger q
+#endif
 
 -- Reduction 𝑥 mod 𝑞 for 0 ≤ 𝑥 < 2𝑞² + 𝑞
 reduce :: Word32 -> Word16
diff --git a/src/Block.hs b/src/Block.hs
--- a/src/Block.hs
+++ b/src/Block.hs
@@ -13,7 +13,7 @@
     ( Block, MutableBlock, blockIndex, blockRead, blockWrite
     , foldZipWith, Block.length, mutableContents, getMutableLength
     , Block.new, Block.newPinned, Block.thaw, thawPinned
-    , Block.unsafeCast, unsafeCastMut, Block.unsafeFreeze, Block.unsafeThaw
+    , Block.unsafeCast, unsafeCastMut, Block.unsafeFreeze
 #ifdef ML_KEM_TESTING
     , Block.toList
 #endif
@@ -109,6 +109,3 @@
 
 unsafeFreeze :: PrimMonad prim => MutableBlock ty (PrimState prim) -> prim (Block ty)
 unsafeFreeze = unsafeFreezePrimArray
-
-unsafeThaw :: PrimMonad prim => Block ty -> prim (MutableBlock ty (PrimState prim))
-unsafeThaw = unsafeThawPrimArray
diff --git a/src/BlockN.hs b/src/BlockN.hs
--- a/src/BlockN.hs
+++ b/src/BlockN.hs
@@ -77,7 +77,7 @@
 index :: PrimType a => BlockN marking n a -> Offset a -> a
 index = SecureBlock.index . unBlockN
 
-replicate :: forall marking n a. (Classified marking, KnownNat n, PrimType a) => a -> BlockN marking n a
+replicate :: (Classified marking, KnownNat n, PrimType a) => a -> BlockN marking n a
 replicate = create . const
 
 #ifdef ML_KEM_TESTING
@@ -96,15 +96,16 @@
 toList = SecureBlock.toList . unBlockN
 #endif
 
-create :: forall marking n ty. (Classified marking, KnownNat n, PrimType ty)
+create :: (Classified marking, KnownNat n, PrimType ty)
        => (Offset ty -> ty)
        -> BlockN marking n ty
-create initializer = runNew (Proxy :: Proxy marking) $ iterSet initializer
+create initializer = runNew Proxy $ iterSet initializer
 {-# INLINE create #-}
 
 map :: (Classified marking, KnownNat n, PrimType a, PrimType b)
     => (a -> b) -> BlockN marking n a -> BlockN marking n b
-map f (BlockN !a) = create $ \(Offset i) -> f (SecureBlock.index a (Offset i))
+map f (BlockN a) = BlockN.seq a $
+    create $ \(Offset i) -> f (SecureBlock.index a (Offset i))
 {-# INLINE [2] map #-}
 
 mapEndo :: (Classified marking, KnownNat n, PrimType a)
@@ -151,8 +152,8 @@
 
 zipWith :: (Classified mc, KnownNat n, PrimType a, PrimType b, PrimType c)
         => (a -> b -> c) -> BlockN ma n a -> BlockN mb n b -> BlockN mc n c
-zipWith f (BlockN !a) (BlockN !b) =
-    create $ \(Offset i) ->
+zipWith f (BlockN a) (BlockN b) =
+    BlockN.seq a $ BlockN.seq b $ create $ \(Offset i) ->
         f (SecureBlock.index a (Offset i)) (SecureBlock.index b (Offset i))
 {-# INLINE [2] zipWith #-}
 
@@ -220,7 +221,7 @@
 iterMapIxContext :: (EqPrimSize a b, Classified marking, KnownNat n) => (Int -> a -> b) -> Context (BlockN marking n a) -> Context (BlockN marking n b)
 iterMapIxContext f = mapContext m
   where m = MapF { mapUpdate = unsafeMapIx f
-                 , mapInit = \x -> newF >>= \mb -> Prelude.seq x (iterSet (g x) mb) >> return mb
+                 , mapInit = \x -> newF >>= \mb -> mb <$ Prelude.seq x (iterSet (g x) mb)
                  }
         g x (Offset i) = f i (index x (Offset i))
 {-# INLINE [1] iterMapIxContext #-}
diff --git a/src/Crypto.hs b/src/Crypto.hs
--- a/src/Crypto.hs
+++ b/src/Crypto.hs
@@ -20,7 +20,7 @@
 import Crypto.Hash.Algorithms
 import Crypto.Hash.IO
 
-import Control.Exception (assert, mask_)
+import Control.Exception (assert, finally)
 import Control.Monad
 import Control.Monad.ST
 
@@ -95,17 +95,21 @@
     constEqW a b
         | Block.length a /= Block.length b = falseW
         | otherwise = Block.foldZipWith (\mask x y -> mask `andW` eqW x y) trueW a b
+    {-# NOINLINE constEqW #-}
 
 instance ConstEqW (ScrubbedBlock Word) where
     constEqW a b
         | ScrubbedBlock.length a /= ScrubbedBlock.length b = falseW
         | otherwise = ScrubbedBlock.foldZipWith (\mask x y -> mask `andW` eqW x y) trueW a b
+    {-# NOINLINE constEqW #-}
 
 instance ConstEqW Bytes where
     constEqW = bytesConstEqW
+    {-# NOINLINE constEqW #-}
 
 instance ConstEqW ScrubbedBytes where
     constEqW = bytesConstEqW
+    {-# NOINLINE constEqW #-}
 
 bytesConstEqW :: (ByteArrayAccess bs1, ByteArrayAccess bs2) => bs1 -> bs2 -> BoolW
 bytesConstEqW a b
@@ -149,6 +153,7 @@
 constSelectBytes :: BoolW -> ScrubbedBytes -> ScrubbedBytes -> ScrubbedBytes
 constSelectBytes (BoolW !mask) = Crypto.zipWith f
   where f yes no = (mask .&. yes) .|. (complement mask .&. no)
+{-# NOINLINE constSelectBytes #-}
 
 -- This version of snoc accepts a more general input and uses internally a call
 -- to copyByteArrayToPtr, so it does not need a trampoline when the input is
@@ -227,8 +232,14 @@
 
 hashWith :: forall marking a ba. (HashAlgorithm a, ByteArrayAccess ba) => a -> ba -> Builder marking
 hashWith a ba = Builder.unsafeCreate (hashDigestSize a) $ \dig ->
-    hashMutableInit >>= \ctx -> mask_ $ do
+    withScrubbedContext a $ \ctx -> do
         hashMutableUpdate (ctx :: MutableContext a) ba
-        B.withByteArray ctx $ \pctx -> do
+        B.withByteArray ctx $ \pctx ->
             hashInternalFinalize (castPtr pctx :: Ptr (Context a)) dig
-            fillBytes pctx 0 (B.length ctx)
+
+withScrubbedContext :: HashAlgorithm a => a -> (MutableContext a -> IO b) -> IO b
+withScrubbedContext _ f =
+    hashMutableInit >>= \ctx -> f ctx `finally` eraseBytes ctx
+
+eraseBytes :: ByteArrayAccess ba => ba -> IO ()
+eraseBytes ctx = B.withByteArray ctx $ \p -> fillBytes p 0 (B.length ctx)
diff --git a/src/Endian.hs b/src/Endian.hs
new file mode 100644
--- /dev/null
+++ b/src/Endian.hs
@@ -0,0 +1,43 @@
+-- |
+-- Module      : Endian
+-- License     : BSD-3-Clause
+-- Copyright   : (c) 2026 Olivier Chéron
+--
+-- Endianness utilities
+--
+{-# LANGUAGE CPP #-}
+module Endian
+    ( B.BE, B.LE, fromLE, toLE
+    ) where
+
+#include "MachDeps.h"
+
+-- Little-endian conversion in `memory` / `ram` is avoided at compile
+-- time only for AMD/Intel, here we will short circuit on ARM too
+#if (defined(arm_HOST_ARCH) || defined(aarch64_HOST_ARCH)) \
+    && !defined(WORDS_BIGENDIAN)
+#define MLKEM_FORCE_LITTLE_ENDIAN_ARCH 1
+#endif
+
+#ifdef MLKEM_FORCE_LITTLE_ENDIAN_ARCH
+#else
+import Data.Memory.Endian (ByteSwap)
+#endif
+
+import qualified Data.Memory.Endian as B
+
+#ifdef MLKEM_FORCE_LITTLE_ENDIAN_ARCH
+fromLE :: B.LE a -> a
+fromLE = B.unLE  -- unwrap constructor with no byte swapping
+#else
+fromLE :: ByteSwap a => B.LE a -> a
+fromLE = B.fromLE  -- byte swap if necessary
+#endif
+
+#ifdef MLKEM_FORCE_LITTLE_ENDIAN_ARCH
+toLE :: a -> B.LE a
+toLE = B.LE  -- wrap constructor with no byte swapping
+#else
+toLE :: ByteSwap a => a -> B.LE a
+toLE = B.toLE  -- byte swap if necessary
+#endif
diff --git a/src/Fusion.hs b/src/Fusion.hs
--- a/src/Fusion.hs
+++ b/src/Fusion.hs
@@ -51,10 +51,10 @@
 
 thawContext :: Fusion a => a -> Context a
 thawContext a = Context $ thawF a
-{-# INLINE [0] thawContext #-}
+{-# INLINE CONLIKE [0] thawContext #-}
 
 modifyContext :: (forall s. Mut a s -> ST s ()) -> Context a -> Context a
-modifyContext f = bindContext $ \ma -> f ma >> return ma
+modifyContext f = bindContext $ \ma -> ma <$ f ma
 
 mapContext :: MapF a b -> Context a -> Context b
 mapContext m = bindContext (mapUpdate m)
diff --git a/src/Machine.hs b/src/Machine.hs
--- a/src/Machine.hs
+++ b/src/Machine.hs
@@ -22,19 +22,12 @@
     || defined(powerpc64le_HOST_ARCH)
 #define MLKEM_ALLOW_UNALIGNED_OP 1
 
--- Little-endian conversion in `memory` / `ram` is avoided at compile
--- time only for AMD/Intel, here we will short circuit on ARM too
-#if (defined(arm_HOST_ARCH) || defined(aarch64_HOST_ARCH)) \
-    && !defined(WORDS_BIGENDIAN)
-#define MLKEM_FORCE_LITTLE_ENDIAN_ARCH 1
 #endif
 
-#endif
-
 import Control.Exception (assert)
 
 #ifdef MLKEM_ALLOW_UNALIGNED_OP
-import qualified Data.Memory.Endian as B
+import qualified Endian as B
 #endif
 
 import Data.Bits
@@ -52,18 +45,10 @@
 type WordLE = B.LE WordM
 
 fromLE :: WordLE -> WordM
-#ifdef MLKEM_FORCE_LITTLE_ENDIAN_ARCH
-fromLE = B.unLE  -- unwrap constructor with no byte swapping
-#else
-fromLE = B.fromLE  -- byte swap if necessary
-#endif
+fromLE = B.fromLE
 
 toLE :: WordM -> WordLE
-#ifdef MLKEM_FORCE_LITTLE_ENDIAN_ARCH
-toLE = B.LE  -- wrap constructor with no byte swapping
-#else
-toLE = B.toLE  -- byte swap if necessary
-#endif
+toLE = B.toLE
 
 #else
 
diff --git a/src/ScrubbedBlock.hs b/src/ScrubbedBlock.hs
--- a/src/ScrubbedBlock.hs
+++ b/src/ScrubbedBlock.hs
@@ -72,7 +72,7 @@
 checkPinned b = assertPinned b (ScrubbedBlock b)
 
 scrubbed :: PrimMonad prim => MutableBlock ty (PrimState prim) -> prim (MutableBlock ty (PrimState prim))
-scrubbed b = unsafePrimFromIO (scheduleBlockScrubbing b >> return b)
+scrubbed b = unsafePrimFromIO (b <$ scheduleBlockScrubbing b)
 
 wakeUpAfterInception :: MutableBlock ty s -> MutableBlock ty RealWorld
 wakeUpAfterInception = unsafeCoerce  -- sometimes disappointing
diff --git a/tests/Tests.hs b/tests/Tests.hs
--- a/tests/Tests.hs
+++ b/tests/Tests.hs
@@ -24,6 +24,7 @@
 import GHC.IO.Exception (IOErrorType(..))
 
 import System.Directory (doesFileExist)
+import System.FileLock (FileLock, SharedExclusive(..), unlockFile, withFileLock)
 import System.IO.Error (catchIOError, mkIOError)
 import System.Process (readProcess)
 
@@ -180,16 +181,30 @@
   where
     scriptPath = "tests/get-vectors.sh"
     free _ = return ()
-    alloc = do
+    whenNeeded action = do
         keyGenExists <- doesFileExist "tests/keyGen.json.gz"
         encapDecapExists <- doesFileExist "tests/encapDecap.json.gz"
-        unless (keyGenExists && encapDecapExists) $ catchIOError
+        unless (keyGenExists && encapDecapExists) action
+    alloc = withTestLock Shared $ \lock -> whenNeeded $ do
+        unlockFile lock  -- sanity before lock upgrade
+        withTestLock Exclusive $ \_ -> whenNeeded $ catchIOError
             (void $ readProcess "/bin/sh" [scriptPath] "")
             (\e ->
                 let msg = "Could not download test vectors, you will need to run the script `" ++
                             scriptPath ++ "' manually. Script failure was: " ++ show e
                  in ioError (mkIOError OtherError msg Nothing Nothing)
             )
+
+withTestLock :: SharedExclusive -> (FileLock -> IO a) -> IO a
+withTestLock mode what = do
+    -- locking a hidden file in the directory prevents a race condition between
+    -- two instances of the test suite trying both to download the test vectors
+    -- (otherwise one instance may try to run with files not fully downloaded
+    -- yet by the other instance)
+    let path = "tests/.lock"
+    exists <- doesFileExist path
+    unless exists $ writeFile path "DO NOT DELETE"
+    withFileLock path mode what
 
 keyGenVectors :: (String -> IO ()) -> Assertion
 keyGenVectors step = do
diff --git a/tests/get-vectors.sh b/tests/get-vectors.sh
--- a/tests/get-vectors.sh
+++ b/tests/get-vectors.sh
@@ -2,7 +2,7 @@
 
 DESTDIR="`dirname "$0"`"
 
-REF=commit/cbb097019deb25eb966932ee293d89417482d8bc
+REF=commit/9d33b0c138bee155d78a3feae3bf3d8d79057652
 CURL=curl
 
 for KEY in keyGen encapDecap; do
