diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,56 @@
 
 `unicode-collation` uses [PVP Versioning](https://pvp.haskell.org).
 
+## 0.1.3.7
+
+  * Docs: recommend `sortOn (sortKey c)` over `sortBy (collate c)`.
+    `collate` recomputes the sort key of each argument on every
+    comparison, whereas `sortOn` computes each key only once.  Update
+    the headline example and guidance accordingly.
+
+  * Replace IntSet lookups with range checks in implicit weight calculation.
+    `calculateImplicitWeight` previously tested CJK/ideograph
+    membership using large IntSets defined in its where-clause.
+    Since the data is essentially contiguous ranges, replace the
+    IntSets with simple range-comparison predicates. This removes
+    is ~25% faster on CJK-heavy sorting at -O1. Also drop a
+    duplicate range, remove the now-unused Data.IntSet import,
+    and correct a misleading (harmless, due to Word16 truncation)
+    0x7FFFF mask to 0x7FFF in the fallback case.
+
+  * Test: guard implicit-weight ideograph ranges against data drift.
+    The CJK/Tangut/Nushu/Khitan ranges in Text.Collate.Collation are
+    hand-coded and decoupled from the shipped Unicode data.  Add a test
+    that enumerates every code point the data marks as an ideograph
+    (from the @implicitweights directives in allkeys.txt and the CJK
+    Ideograph First/Last markers in UnicodeData.txt) and asserts each
+    receives an ideographic implicit primary weight (< 0xFBC0) rather
+    than the generic unassigned bucket.  A future data bump that does
+    not update the ranges will now fail, naming the uncovered points.
+
+  * Benchmarks: force full sorts with `nf` instead of `whnf`.
+    `whnf` only evaluated the result list to its first cons cell, so the
+    benchmarks measured finding the minimum rather than a full sort. Using
+    `nf` forces the entire sorted list, giving a representative measurement.
+
+  * Add `bench-sortkey` benchmark comparing `collate` with `sortKey`.
+    This self-contained benchmark (no text-icu/QuickCheck deps) measures
+    `sortBy (collate c)` against `sortOn (sortKey c)` across several input
+    shapes, demonstrating the speedup from computing each sort key once.
+
+  * Add an `icu-benchmark` flag (default off) guarding the existing
+    text-icu benchmark, so the new benchmark can be built without the ICU
+    C library.
+
+  * Add a CJK-focused benchmark exercising the implicit weight path.
+
+  * Add flake.nix.
+
+  * CI: Support GHC 9.10 (David Binder), 9.12 (Austin Ziegler), and
+    9.14 (Li-yao Xia).
+
+  * Add cabal.project.
+
 ## 0.1.3.6
 
   * Update to build with GHC 9.8 (Laurent P. René de Cotret).
diff --git a/benchmark/Main.hs b/benchmark/Main.hs
--- a/benchmark/Main.hs
+++ b/benchmark/Main.hs
@@ -19,10 +19,13 @@
   (randomTexts :: [Text]) <- generate (infiniteListOf arbitrary)
   (randomLatinStrings :: [String]) <-
       generate (infiniteListOf (listOf (elements latinChars)))
+  (randomCJKStrings :: [String]) <-
+      generate (infiniteListOf (listOf (elements cjkChars)))
   (randomAsciiTexts :: [Text]) <-
     generate (infiniteListOf (arbitrary `suchThat` T.all isAscii))
   let tenThousand = take 10000 randomTexts
   let tenThousandLatin = map T.pack $ take 10000 randomLatinStrings
+  let tenThousandCJK = map T.pack $ take 10000 randomCJKStrings
   let tenThousandLatinNFD = map (T.pack . map chr . toNFD . map ord . T.unpack)
                               tenThousandLatin
   let tenThousandString = map T.unpack tenThousand
@@ -33,28 +36,43 @@
   let collateString = collateWithUnpacker (collatorFor "en") id
   defaultMain
     [ bench "sort a list of 10000 random Texts (en)"
-        (whnf (sortBy (collate (collatorFor "en"))) tenThousand)
+        (nf (sortBy (collate (collatorFor "en"))) tenThousand)
     , bench "sort same list with text-icu (en)"
-        (whnf (sortBy (ICU.collate (icuCollator "en"))) tenThousand)
+        (nf (sortBy (ICU.collate (icuCollator "en"))) tenThousand)
     , bench "sort a list of 10000 Texts (composed latin) (en)"
-        (whnf (sortBy (collate (collatorFor "en"))) tenThousandLatin)
+        (nf (sortBy (collate (collatorFor "en"))) tenThousandLatin)
     , bench "sort same list with text-icu (en)"
-        (whnf (sortBy (ICU.collate (icuCollator "en"))) tenThousandLatin)
+        (nf (sortBy (ICU.collate (icuCollator "en"))) tenThousandLatin)
     , bench "sort same list but pre-normalized (en-u-kk-false)"
-        (whnf (sortBy (collate (collatorFor "en-u-kk-false"))) tenThousandLatinNFD)
+        (nf (sortBy (collate (collatorFor "en-u-kk-false"))) tenThousandLatinNFD)
+    , bench "sort a list of 10000 CJK Texts (en, implicit weights)"
+        (nf (sortBy (collate (collatorFor "en"))) tenThousandCJK)
+    , bench "sort same CJK list with text-icu (en)"
+        (nf (sortBy (ICU.collate (icuCollator "en"))) tenThousandCJK)
     , bench "sort a list of 10000 ASCII Texts (en)"
-        (whnf (sortBy (collate (collatorFor "en"))) tenThousandAscii)
+        (nf (sortBy (collate (collatorFor "en"))) tenThousandAscii)
     , bench "sort same list with text-icu (en)"
-        (whnf (sortBy (ICU.collate (icuCollator "en"))) tenThousandAscii)
+        (nf (sortBy (ICU.collate (icuCollator "en"))) tenThousandAscii)
     , bench "sort a list of 10000 random Texts that agree in first 32 chars"
-        (whnf (sortBy (collate (collatorFor "en"))) tenThousandLong)
+        (nf (sortBy (collate (collatorFor "en"))) tenThousandLong)
     , bench "sort same list with text-icu (en)"
-        (whnf (sortBy (ICU.collate (icuCollator "en"))) tenThousandLong)
+        (nf (sortBy (ICU.collate (icuCollator "en"))) tenThousandLong)
     , bench "sort a list of 10000 identical Texts (en)"
-        (whnf (sortBy collateString) (replicate 10000 "ḀḁḂḃḄḅḆḇḈḉḊḋḌḍḎḏḐḑḒḓḔ"))
+        (nf (sortBy collateString) (replicate 10000 "ḀḁḂḃḄḅḆḇḈḉḊḋḌḍḎḏḐḑḒḓḔ"))
     , bench "sort a list of 10000 random Strings (en)"
-        (whnf (sortBy collateString) tenThousandString)
+        (nf (sortBy collateString) tenThousandString)
     ]
+
+-- A mix of CJK ideographs, all of which are assigned implicit weights
+-- (they are not listed individually in the DUCET), so this exercises
+-- 'calculateImplicitWeight'.  Includes the "Core Han" path (CJK Unified
+-- Ideographs, BMP) as well as the "All Other Han Unified" path (Extension A
+-- and a sample of supplementary Extension B).
+cjkChars :: [Char]
+cjkChars = map chr $
+     [0x4E00..0x9FFF]    -- CJK Unified Ideographs (BMP, Core Han)
+  ++ [0x3400..0x4DBF]    -- CJK Extension A
+  ++ [0x20000..0x20FFF]  -- sample of CJK Extension B (supplementary)
 
 latinChars :: [Char]
 latinChars = "ḀḁḂḃḄḅḆḇḈḉḊḋḌḍḎḏḐḑḒḓḔḕḖḗḘḙḚḛḜḝḞḟḠḡḢḣḤḥḦḧḨḩḪḫḬḭḮḯḰḱḲḳḴḵḶḷḸḹḺḻḼḽḾḿṀṁṂṃṄṅṆṇṈṉṊṋṌṍṎṏṐṑṒṓṔṕṖṗṘṙṚṛṜṝṞṟṠṡṢṣṤṥṦṧṨṩṪṫṬṭṮṯṰṱṲṳṴṵṶṷṸṹṺṻṼṽṾṿ‐‑‒–—―‖‗‘’‚‛“”„‟†‡•‣․‥…"
diff --git a/benchmark/SortKey.hs b/benchmark/SortKey.hs
new file mode 100644
--- /dev/null
+++ b/benchmark/SortKey.hs
@@ -0,0 +1,107 @@
+{-# LANGUAGE OverloadedStrings   #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+
+-- | Benchmark comparing two ways of sorting with a 'Collator':
+--
+--   * naive:   @sortBy (collate coll)@         -- recomputes both sort keys
+--                                                 on every comparison
+--   * keyed:   @sortOn (sortKey coll)@         -- computes each sort key once
+--
+-- Inputs are generated with a self-contained xorshift PRNG so this target
+-- depends only on the library, text, deepseq and tasty-bench (no text-icu /
+-- QuickCheck).  Results are forced with 'nf' so the whole sort is measured.
+module Main (main) where
+
+import Test.Tasty.Bench
+import Control.DeepSeq (force)
+import Control.Exception (evaluate)
+import Data.Bits (xor, shiftL, shiftR)
+import Data.Char (chr)
+import Data.List (sortBy, sortOn)
+import Data.Word (Word64)
+import Data.Text (Text)
+import qualified Data.Text as T
+import Text.Collate
+
+-- ---------------------------------------------------------------------------
+-- A tiny deterministic PRNG (xorshift64), so benchmarks are reproducible
+-- and we avoid a QuickCheck dependency.
+
+xorshift :: Word64 -> Word64
+xorshift s0 =
+  let s1 = s0 `xor` (s0 `shiftL` 13)
+      s2 = s1 `xor` (s1 `shiftR` 7)
+  in  s2 `xor` (s2 `shiftL` 17)
+
+randoms :: Word64 -> [Word64]
+randoms = drop 1 . iterate xorshift
+
+-- Map a Word64 to a Char in [lo,hi] (inclusive code point range).
+type CharGen = Word64 -> Char
+
+rangeGen :: Int -> Int -> CharGen
+rangeGen lo hi w = chr (lo + fromIntegral (w `mod` fromIntegral (hi - lo + 1)))
+
+-- Build n Texts. Each text has a length drawn from [minLen,maxLen] and
+-- characters produced by the given CharGen.
+buildTexts :: Int -> (Int, Int) -> CharGen -> [Word64] -> [Text]
+buildTexts 0 _ _ _ = []
+buildTexts n (lo, hi) g ws0 =
+  case ws0 of
+    [] -> []
+    (lenW : ws) ->
+      let len          = lo + fromIntegral (lenW `mod` fromIntegral (hi - lo + 1))
+          (chunk, ws') = splitAt len ws
+          txt          = T.pack (map g chunk)
+      in txt : buildTexts (n - 1) (lo, hi) g ws'
+
+-- A text whose first 25 chars are a shared prefix (worst case for the naive
+-- sort: every comparison must scan past the identical prefix before it can
+-- decide, forcing nearly the whole sort key each time).
+withSharedPrefix :: Text -> Text
+withSharedPrefix = ("The quick brown fox jumps" <>)
+
+-- ---------------------------------------------------------------------------
+-- The two sorting strategies.
+
+naiveSort :: Collator -> [Text] -> [Text]
+naiveSort coll = sortBy (collate coll)
+
+keyedSort :: Collator -> [Text] -> [Text]
+keyedSort coll = sortOn (sortKey coll)
+
+-- ---------------------------------------------------------------------------
+
+main :: IO ()
+main = do
+  let coll = collatorFor "en"
+      n    = 10000
+
+  -- Four data shapes, each a forced list of 10000 Texts.
+  ascii <- evaluate $ force $
+             buildTexts n (1, 20) (rangeGen 0x61 0x7A) (randoms 0x1234)
+  latin <- evaluate $ force $   -- Latin Extended Additional: need NFD
+             buildTexts n (1, 20) (rangeGen 0x1E00 0x1EFF) (randoms 0x5678)
+  cjk   <- evaluate $ force $   -- implicit weights path
+             buildTexts n (1, 20) (rangeGen 0x4E00 0x9FFF) (randoms 0x9ABC)
+  long  <- evaluate $ force $ map withSharedPrefix $
+             buildTexts n (1, 20) (rangeGen 0x61 0x7A) (randoms 0xDEF0)
+
+  defaultMain
+    [ bgroup "ascii"
+        [ bench "naive  (collate)" $ nf (naiveSort coll) ascii
+        , bench "keyed  (sortKey)" $ nf (keyedSort coll) ascii
+        ]
+    , bgroup "latin (needs NFD)"
+        [ bench "naive  (collate)" $ nf (naiveSort coll) latin
+        , bench "keyed  (sortKey)" $ nf (keyedSort coll) latin
+        ]
+    , bgroup "cjk (implicit weights)"
+        [ bench "naive  (collate)" $ nf (naiveSort coll) cjk
+        , bench "keyed  (sortKey)" $ nf (keyedSort coll) cjk
+        ]
+    , bgroup "shared-prefix (worst case)"
+        [ bench "naive  (collate)" $ nf (naiveSort coll) long
+        , bench "keyed  (sortKey)" $ nf (keyedSort coll) long
+        ]
+    ]
diff --git a/src/Text/Collate.hs b/src/Text/Collate.hs
--- a/src/Text/Collate.hs
+++ b/src/Text/Collate.hs
@@ -11,9 +11,9 @@
 instance of 'Collator' (together with the @OverloadedStrings@
 extension):
 
->>> import Data.List (sortBy)
+>>> import Data.List (sortOn)
 >>> import qualified Data.Text.IO as T
->>> mapM_ T.putStrLn $ sortBy (collate "en-US") ["𝒶bc","abC","𝕒bc","Abc","abç","äbc"]
+>>> mapM_ T.putStrLn $ sortOn (sortKey "en-US") ["𝒶bc","abC","𝕒bc","Abc","abç","äbc"]
 abC
 𝒶bc
 𝕒bc
@@ -34,8 +34,13 @@
 𝕒bc
 
 A 'Collator' provides a function 'collate' that compares two texts,
-and a function 'sortKey' that returns the sort key.  Most users will
-just need 'collate'.
+and a function 'sortKey' that returns the sort key.  Use 'collate'
+when you only need to compare two values.  To sort a list, prefer
+@sortOn (sortKey collator)@ (as above) over @sortBy (collate collator)@:
+'collate' recomputes the sort key of each argument on every
+comparison, whereas 'sortOn' (from "Data.List") computes each key only
+once.  This is substantially faster when sorting large lists (in
+benchmarks, roughly 3-5x).
 
 >>> let de = collatorFor "de"
 >>> let se = collatorFor "se"
diff --git a/src/Text/Collate/Collation.hs b/src/Text/Collate/Collation.hs
--- a/src/Text/Collate/Collation.hs
+++ b/src/Text/Collate/Collation.hs
@@ -19,7 +19,6 @@
  )
 where
 
-import qualified Data.IntSet as IntSet
 import qualified Data.Text as T
 import qualified Data.Text.Read as TR
 import Data.Text (Text)
@@ -219,34 +218,37 @@
 -- @implicitweights 18D00..18D8F; FB00 # Tangut Supplement
 -- @implicitweights 1B170..1B2FF; FB01 # Nushu
 -- @implicitweights 18B00..18CFF; FB02 # Khitan Small Script
+-- from PropList.txt in unicode data:
+isUnifiedIdeograph :: Int -> Bool
+isUnifiedIdeograph cp =
+     (cp >= 0x3400  && cp <= 0x4DBF)
+  || (cp >= 0x4E00  && cp <= 0x9FFC)
+  || (cp >= 0xFA0E  && cp <= 0xFA0F)
+  || cp == 0xFA11
+  || (cp >= 0xFA13  && cp <= 0xFA14)
+  || cp == 0xFA1F
+  || cp == 0xFA21
+  || (cp >= 0xFA23  && cp <= 0xFA24)
+  || (cp >= 0xFA27  && cp <= 0xFA29)
+  || (cp >= 0x20000 && cp <= 0x2A6DD)
+  || (cp >= 0x2A700 && cp <= 0x2B734)
+  || (cp >= 0x2B740 && cp <= 0x2B81D)
+  || (cp >= 0x2B820 && cp <= 0x2CEA1)
+  || (cp >= 0x2CEB0 && cp <= 0x2EBE0)
+  || (cp >= 0x30000 && cp <= 0x3134A)
+
+-- from Blocks.txt in unicode data:
+isCjkCompatibilityIdeograph :: Int -> Bool
+isCjkCompatibilityIdeograph cp = cp >= 0xF900 && cp <= 0xFAFF
+
+isCjkUnifiedIdeograph :: Int -> Bool
+isCjkUnifiedIdeograph cp = cp >= 0x4E00 && cp <= 0x9FFF
+
 calculateImplicitWeight :: Int -> [CollationElement]
 calculateImplicitWeight cp =
   [CollationElement False (fromIntegral aaaa) 0x0020 0x0002 0xFFFF,
    CollationElement False (fromIntegral bbbb) 0 0 0xFFFF]
  where
-  range x y = IntSet.fromList [x..y]
-  singleton = IntSet.singleton
-  union = IntSet.union
-  -- from PropList.txt in unicode data:
-  unifiedIdeographs =    range 0x3400 0x4DBF `union`
-                         range 0x4E00 0x9FFC `union`
-                         range 0xFA0E 0xFA0F `union`
-                         singleton 0xFA11 `union`
-                         range 0xFA13 0xFA14 `union`
-                         singleton 0xFA1F `union`
-                         singleton 0xFA21 `union`
-                         range 0xFA23 0xFA24 `union`
-                         range 0xFA27 0xFA29 `union`
-                         range 0x20000 0x2A6DD `union`
-                         range 0x2A700 0x2B734 `union`
-                         range 0x2B740 0x2B81D `union`
-                         range 0x2B820 0x2CEA1 `union`
-                         range 0x2CEB0 0x2EBE0 `union`
-                         range 0x2CEB0 0x2EBE0 `union`
-                         range 0x30000 0x3134A
-  -- from Blocks.txt in unicode data:
-  cjkCompatibilityIdeographs = range 0xF900 0xFAFF
-  cjkUnifiedIdeographs = range 0x4E00 0x9FFF
   (aaaa, bbbb) =
     case cp of
     _ | cp >= 0x17000 , cp <= 0x18AFF -- Tangut and Tangut Components
@@ -257,14 +259,14 @@
         -> (0xFB01, (cp - 0x1B170) .|. 0x8000)
       | cp >= 0x18B00 , cp <= 0x18CFF -- Khitan Small Script
         -> (0xFB02, (cp - 0x18B00) .|. 0x8000)
-      | cp `IntSet.member` unifiedIdeographs &&
-        (cp `IntSet.member` cjkUnifiedIdeographs ||
-         cp `IntSet.member` cjkCompatibilityIdeographs)  -- Core Han
+      | isUnifiedIdeograph cp &&
+        (isCjkUnifiedIdeograph cp ||
+         isCjkCompatibilityIdeograph cp)  -- Core Han
         -> (0xFB40 + (cp `shiftR` 15), (cp .&. 0x7FFF) .|. 0x8000)
-      | cp `IntSet.member` unifiedIdeographs -- All Other Han Unified ?
+      | isUnifiedIdeograph cp -- All Other Han Unified ?
         -> (0xFB80 + (cp `shiftR` 15), (cp .&. 0x7FFF) .|. 0x8000)
       | otherwise
-        -> (0xFBC0 + (cp `shiftR` 15), (cp .&. 0x7FFFF) .|. 0x8000)
+        -> (0xFBC0 + (cp `shiftR` 15), (cp .&. 0x7FFF) .|. 0x8000)
 
 -- | Parse a 'Collation' from a Text in the format of @allkeys.txt@.
 parseCollation :: Text -> Collation
diff --git a/test/unit.hs b/test/unit.hs
--- a/test/unit.hs
+++ b/test/unit.hs
@@ -21,11 +21,13 @@
 main :: IO ()
 main = do
   conformanceTree <- conformanceTests
-  defaultMain (tests conformanceTree)
+  coverageTree <- implicitWeightCoverageTests
+  defaultMain (tests conformanceTree coverageTree)
 
-tests :: TestTree -> TestTree
-tests conformanceTree = testGroup "Tests"
+tests :: TestTree -> TestTree -> TestTree
+tests conformanceTree coverageTree = testGroup "Tests"
   [ conformanceTree
+  , coverageTree
   , testCase "Sorting test 1" $
     sortBy (collate ourCollator) ["hi", "hit", "hít", "hat", "hot",
                        "naïve", "nag", "name"] @?=
@@ -172,6 +174,87 @@
                      es -> assertFailure (unlines es))
          $ map (conformanceTestWith coll)
               (zip3 (map fst xs) (map snd xs) (drop 1 (map snd xs)))
+
+-- | Guard against the hand-coded ideograph ranges in
+-- 'Text.Collate.Collation' (used by 'calculateImplicitWeight') drifting
+-- out of sync with the shipped Unicode data.  Every code point that the
+-- data marks as an ideograph must receive an /ideographic/ implicit
+-- primary weight; anything not covered by the hand-coded ranges falls to
+-- the generic unassigned bucket at @0xFBC0@ and beyond.  We enumerate the
+-- full set (not just range endpoints) so that adding a new ideograph block
+-- to the data without updating the ranges is caught.
+--
+-- Sources of truth, both in the shipped data:
+--
+--   * @\@implicitweights@ directives in @data\/allkeys.txt@
+--     (Tangut, Nushu, Khitan -> 0xFB00..0xFB02)
+--   * @\<... Ideograph ..., First\/Last\>@ markers in
+--     @data\/UnicodeData.txt@ (CJK Unified + extensions -> 0xFB40\/0xFB80)
+implicitWeightCoverageTests :: IO TestTree
+implicitWeightCoverageTests = do
+  putStrLn "Loading implicit-weight coverage data..."
+  allkeys <- B8.readFile "data/allkeys.txt"
+  ucd     <- B8.readFile "data/UnicodeData.txt"
+  let ranges = implicitWeightDirectives allkeys ++ ideographRanges ucd
+      cps    = concatMap (\(lo, hi) -> [lo .. hi]) ranges
+  return $ testCase "Implicit weights cover all ideographs in shipped data" $
+    case [ (cp, w) | cp <- cps, let w = implicitPrimary cp, w >= 0xFBC0 ] of
+      []  -> return ()
+      bad -> assertFailure $
+               "Code points marked as ideographs in the data receive the "
+            ++ "generic\n(>= 0xFBC0) implicit weight; the hand-coded ranges "
+            ++ "in\nText.Collate.Collation are stale (" ++ show (length bad)
+            ++ " affected):\n"
+            ++ unlines [ printf "  U+%05X -> primary %04X" cp w
+                       | (cp, w) <- take 40 bad ]
+
+-- | Primary weight assigned to a single code point (head of its sort key).
+implicitPrimary :: Int -> Int
+implicitPrimary cp =
+  case sortKey rootCollator (T.singleton (chr cp)) of
+    SortKey (w : _) -> fromIntegral w
+    SortKey []      -> 0
+
+-- | Parse the @\@implicitweights LO..HI; WEIGHT@ directives from
+-- @allkeys.txt@ into inclusive code-point ranges.
+implicitWeightDirectives :: B8.ByteString -> [(Int, Int)]
+implicitWeightDirectives = mapMaybe parseLine . B8.lines
+ where
+  parseLine ln =
+    let t = TE.decodeLatin1 ln
+     in if "@implicitweights" `T.isPrefixOf` t
+           then dotRange (T.takeWhile (/= ';') (T.drop 16 t))
+           else Nothing
+  dotRange t =
+    case T.splitOn ".." (T.strip t) of
+      [a, b] -> (,) <$> hexInt a <*> hexInt b
+      _      -> Nothing
+
+-- | Parse the @\<... Ideograph ..., First\/Last\>@ range markers from
+-- @UnicodeData.txt@ into inclusive code-point ranges.
+ideographRanges :: B8.ByteString -> [(Int, Int)]
+ideographRanges = pairUp . mapMaybe marker . B8.lines
+ where
+  marker ln =
+    case T.splitOn ";" (TE.decodeLatin1 ln) of
+      (cpF : nameF : _)
+        | "Ideograph" `T.isInfixOf` nameF
+        , Just cp <- hexInt cpF
+        -> if      ", First>" `T.isSuffixOf` nameF then Just (cp, False)
+           else if ", Last>"  `T.isSuffixOf` nameF then Just (cp, True)
+           else Nothing
+      _ -> Nothing
+  pairUp ((lo, False) : (hi, True) : rest) = (lo, hi) : pairUp rest
+  pairUp (_ : rest)                        = pairUp rest
+  pairUp []                                = []
+
+-- | Parse a (possibly space-padded) hexadecimal integer, requiring that it
+-- consume the whole input.
+hexInt :: Text -> Maybe Int
+hexInt t =
+  case TR.hexadecimal (T.strip t) of
+    Right (n, rest) | T.null (T.strip rest) -> Just n
+    _                                       -> Nothing
 
 conformanceTestWith :: Collator -> (Int, Text, Text) -> Either String ()
 conformanceTestWith coll (lineNo, txt1, txt2) =
diff --git a/unicode-collation.cabal b/unicode-collation.cabal
--- a/unicode-collation.cabal
+++ b/unicode-collation.cabal
@@ -1,6 +1,6 @@
 cabal-version:       2.2
 name:                unicode-collation
-version:             0.1.3.6
+version:             0.1.3.7
 synopsis:            Haskell implementation of the Unicode Collation Algorithm
 description:         This library provides a pure Haskell implementation of
                      the Unicode Collation Algorithm described at
@@ -35,6 +35,7 @@
                      GHC == 9.4.2
                      GHC == 9.6.3
                      GHC == 9.8.1
+                     GHC == 9.10.1
 
 source-repository head
   type:                git
@@ -49,8 +50,12 @@
   Description:         Build the unicode-collate executable.
   Default:             False
 
+flag icu-benchmark
+  Description:         Build the text-icu comparison benchmark (needs ICU C lib).
+  Default:             False
+
 common common-options
-  build-depends:       base >= 4.11 && < 4.20
+  build-depends:       base >= 4.11 && < 4.23
 
   ghc-options:         -Wall
                        -Wcompat
@@ -149,4 +154,19 @@
                      , text-icu >= 0.8
                      , quickcheck-instances
                      , QuickCheck
+  ghc-options:         -rtsopts -with-rtsopts=-A8m
+  if flag(icu-benchmark)
+     buildable:        True
+  else
+     buildable:        False
+
+benchmark bench-sortkey
+  import:              common-options
+  type:                exitcode-stdio-1.0
+  hs-source-dirs:      benchmark
+  main-is:             SortKey.hs
+  build-depends:       tasty-bench
+                     , unicode-collation
+                     , text
+                     , deepseq
   ghc-options:         -rtsopts -with-rtsopts=-A8m
