24 dias de Hackage, 2015 - dia 18 - vector, vector-algorithms: Libere seu alter-programador C
por Franklin Chen
traduzido por Pedro Yamada
Esse é um artigo escrito por Franklin Chen e traduzido para o português. Ler original.
Encontro HaskellBR São Paulo
Índice de toda a série
O índice de toda a série está no topo do artigo para o dia 1.
Dia 18
É comum em programação, que a estrutura de dados a ser usada é um array estilo C, seja ele tratado como mutável ou imutável.
Idealmente, eu não teria que escrever esse artigo, mas eu tenho a impressão de
que muitos recem chegados ao Haskell não sabem sobre o
vector
, a biblioteca para
arrays estilo C, e usam listas onde arrays se encaixariam melhor no
problema. Arrays são eficientes para indexar e também são eficientes quanto
ao cache da CPU e a memória, sendo alocados em um bloco de memória, ao invés de
espalhados por meio de um monte de ponteiros.
A terminologia usada para arrays no ecossistema do Haskell é confusa porque
Haskell, nos anos 90, originalmente veio com uma estrutura de dados chamada
Array
, e há até um pacote
array
,
mas na prática eu nunco o uso porque ele é mais genérico e estranho que a
estrutura de dados simples exposta mais tarde chamada “vetores” [N.T. “vectors”]
(pela falta de um nome melhor).
Há bons tutoriais sobre o vector
que eu recomendo:
Achei que seria útil dar um exemplo completo de usar vetores imutáveis e mutáveis. Em particular, vamos usar vetores unboxed que são especializados e, portanto, não contém nenhuma indireção e são basicamente equivalentes aos arrays estilo C. Vamos fazer um pouco de programação de baixo nível estilo C.
O problema a ser resolvido: encontrar a mediana de um array de inteiros de 8-bits
Aqui está um problema encontrado no Programming Praxis:
A mediana de um array é o valor no meio se o array estivesse ordenado: se o array tiver um número impar de items
n
, a mediana é o(n+1)/2
nth maior item no array (que também é o(n+1)/2
nth menor item no array), e se o array tiver um número par de itemsn
, a mediana é a média aritmética don/2
th menor item e don/2
th maior item no array. Por exemplo, a mediana do array[2,4,5,7,3,6,1]
é4
e a mediana do array[5,2,1,6,3,4]
é3.5
.Sua tarefa é escrever um programa que recebe um array de inteiros de 8-bits (possivelmente mas não necessariamente ordenados) e encontra o valor da mediana do array; você deve encontrar um algoritmo que tome tempo linear e espaço constante.
Instalação do vector
O Stackage LTS ainda está no vector-0.10
, mas eu queria usar a
versão 0.11 porque a API
mudou um pouco, então eu adicionei uma restrição de versão apropriada ao
arquivo Cabal.
A importância de ir unboxed
Já que temos que escrever código específico a um array de inteiros de 8-bits, vamos usar vetores unboxed ao invés de boxed (genéricos): vetores boxed contém elementos que são ponteiros para valores, enquanto os unboxed tem elementos que são os próprios valores. Isso é importante para a eficiência, se você sabe de cara qual é o valor dos seus elementos.
Alguns testes
Vamos escrever alguns testes antes de mais nada, assumindo que vamos escrever
um módulo VectorExample
.
Primeiro, alguns imports. Nós vamos usar o operador de indexing em tempo
constante para vetores !
.
module VectorExampleSpec where
import VectorExample
( Value, MedianValue, averageValue
, spaceInefficientMedian, constantSpaceMedian
)
import Data.Vector.Unboxed ((!))
import qualified Data.Vector.Unboxed as V
import qualified Data.Vector.Algorithms.Radix as Radix
Vamos usar HSpec e QuickCheck, e escrever nossos próprios geradores de vetores aleatórios:
import Test.Hspec (Spec, hspec, describe, it, shouldBe)
import Test.Hspec.QuickCheck (prop)
import Test.QuickCheck (Arbitrary(arbitrary, shrink), choose, sized, shrinkList)
Vamos implementar tanto uma versão simples de referência e a inteligente desejada, então vamos estar ambas:
spec :: Spec
spec = do
describe "compute median of vector of 8-bit unsigned integers" $ do
medianSpec "inefficientSpaceMedian" inefficientSpaceMedian
medianSpec "constantSpaceMedian" constantSpaceMedian
Testamos os exemplos fornecidos, assim como propriedades genéricas. Para
ordenamento, nós convenientemente usamos a ordenação radix do pacote
vector-algorithms
:
medianSpec :: String
-> (V.Vector Value -> Maybe MedianValue)
-> Spec
medianSpec description findMedian =
describe description $ do
describe "some examples" $ do
it "handles odd number of elements" $ do
findMedian (V.fromList [2, 4, 5, 7, 3, 6, 1]) `shouldBe` Just 4.0
it "handles nonzero even number of elements" $ do
findMedian (V.fromList [5, 2, 1, 6, 3, 4]) `shouldBe` Just 3.5
describe "properties" $ do
it "handles no elements" $ do
findMedian V.empty `shouldBe` Nothing
prop "handles one element" $ \v ->
findMedian (V.singleton v) == Just (fromIntegral v)
prop "handles odd number of elements" $
\(VectorWithOdd values) ->
let len = V.length values
midIndex = pred (succ len `div` 2)
sorted = V.modify Radix.sort values
in findMedian values == Just (fromIntegral (sorted ! midIndex))
prop "handles positive even number of elements" $
\(VectorWithPositiveEven values) ->
let len = V.length values
midIndex = pred (succ len `div` 2)
sorted = V.modify Radix.sort values
in findMedian values ==
Just (averageValue (fromIntegral (sorted ! midIndex))
(sorted ! succ midIndex))
Os testes de propriedades especificam o que a mediana deve ser, para todos os casos possíveis.
Uma nota sobre mutação
A ordenação radix opera sobre vetores mutáveis, mas estamos usando um vetor
função modify
que tem um tipo pomposo:
modify :: Unbox a => (forall s. MVector s a -> ST s ()) -> Vector a -> Vector a
Ela recebe um callback que recebe um vetor mutável e transforma um vetor
imutável em outro vetor imutável (possivelmente o mesmo vetor mutado
“secretamente” por baixo dos panos). Usa tipos de alta ordem e o
monad transformer de estado ST
,
mas tudo o que você tem de saber é que
Radix.sort
tem um tipo compatível de callback e pode portanto ser composto com modify
:
sort :: forall e m v. (PrimMonad m, MVector v e, Radix e) => v (PrimState m) e -> m ()
Geradores customizados do QuickCheck
Aqui estão alguns geradores rápidos de vetores do tipo que queremos para os testes. Se estivéssemos fazendo isso de verdade, provavelmente gostariamos de escrever shrinkers customizados, ao invés de percorrer listas.
-- | Gene um vetor com um número impar de elementos.
newtype VectorWithOdd a =
VectorWithOdd { getVectorWithOdd :: V.Vector a }
deriving (Show)
instance (Arbitrary a, V.Unbox a) => Arbitrary (VectorWithOdd a) where
arbitrary = sized $ \n -> do
k <- choose (0, n `div` 2)
VectorWithOdd <$> V.replicateM (2*k+1) arbitrary
shrink = map (VectorWithOdd . V.fromList)
. shrinkList shrink
. V.toList
. getVectorWithOdd
-- | Gere um vetor não vazio com um número par de elementos.
newtype VectorWithPositiveEven a =
VectorWithPositiveEven { getVectorWithPositiveEven :: V.Vector a }
deriving (Show)
instance (Arbitrary a, V.Unbox a) => Arbitrary (VectorWithPositiveEven a) where
arbitrary = sized $ \n -> do
k <- choose (1, 1 `max` (n `div` 2))
VectorWithPositiveEven <$> V.replicateM (2*k) arbitrary
shrink = map (VectorWithPositiveEven . V.fromList)
. shrinkList shrink
. V.toList
. getVectorWithPositiveEven
Uma versão ineficiente que viola o requisito de espaço
Para referência (nós podíamos quase ler a partir dos testes do QuickCheck):
{-# LANGUAGE BangPatterns #-}
module VectorExample where
import qualified Data.Word as Word
import qualified Data.Vector.Unboxed as V
import qualified Data.Vector.Unboxed.Mutable as M
import qualified Data.Vector.Algorithms.Radix as Radix
-- | Assuma um inteiro unsigned de 8-bits
type Value = Word.Word8
-- | O que retornar como a mediana, se existir
type MedianValue = Double
-- | A implementação óbvia; basicamente a especificação. O uso da ordenação
-- radix toma tempo linear, mas uma cópia do array original é necessária.
inefficientSpaceMedian :: V.Vector Value -> Maybe MedianValue
inefficientSpaceMedian values
| V.null values = Nothing
| odd len = Just (fromIntegral (atSorted midIndex))
| otherwise = Just (averageValue (atSorted midIndex)
(atSorted (succ midIndex)))
where
len = V.length values
midIndex = pred (succ len `div` 2)
-- Faz uma cópia local mutável para ordenar
atSorted = V.unsafeIndex (V.modify Radix.sort values)
-- | A média de dois valores
averageValue :: Value -> Value -> MedianValue
averageValue a b = (fromIntegral a + fromIntegral b) / 2
Uma nota sobre operações unsafe
Eu prometi que iríamos fazer programação estilo C. Poderíamos usar indexing
com um bounds-check (que atira uma exceção durante runtime quando acessamos
elementos que não estão em um vetor), mas vamos nos “divertir” um pouco, e usar
o unsafeIndex
. Note que isso é inseguro mesmo: vamos pegar o centésimo
elemento de um vetor com 1 Int
:
Data.Vector.Unboxed> unsafeIndex (singleton (5 :: Int)) 100
4931475852
Porque usar operações unsafe? Para ilustração e também porque acontece de que podemos provar (fora do type system) que nós nunca vamos tentar acessar um elemento fora do vetor. Na vida real, eu hesitaria em usar operações unsafe a não ser que ter a performance máxima fosse crítico! Mas nós podemos nos candidatar ao mundo de desastres em potencial do C se quisermos.
Não quero promover usar operações unsafe!! Substitua todas as funções
de hoje chamadas unsafeXyz
por xyz
para ficar tranquilo.
Uma solução inteligente
A função inteligente aloca uma tabela de todas as contegens possíveis (porque sabemos que os valores são inteiros unsigned de 8-bits) e a preenche, então itera pela tabela e descobre onde a mediana deve estar, e quando bate no lugar certo, retorna o valor do meio (se tiver um número impar de valores) ou o meio de dois valores (se tiver um número par de valores).
-- | O número de ocorrências de um inteiro de 8-bits unsigned.
-- Assumimos que não overflow além dos limites de um 'Int'.
type CountOfValue = Int
-- | Create a table of counts for each possible value, since we know
-- the number of values is small and finite.
-- | Crie uma tabela de contagens para cada valor possível, já que sabemos que
-- o número de valores é pequeno e finito.
constantSpaceMedian :: V.Vector Value -> Maybe MedianValue
constantSpaceMedian values
| V.null values = Nothing
| odd len = Just (findMid 0 (numToCount - countOf 0))
| otherwise = Just (findMid2 0 (numToCount - countOf 0))
where
len = V.length values
-- Quantos elementos ordenados que precisamos contar, para chegar ao
-- primeiro valor mediano.
numToCount = succ len `div` 2
-- Crie a tabela de contagens para cada valor possível.
countOf = V.unsafeIndex (makeCountsMutably values)
findMid !i !numRemaining
| numRemaining <= 0 = fromIntegral i
| otherwise = findMid (succ i) (numRemaining - countOf (succ i))
findMid2 !i !numRemaining =
case numRemaining `compare` 0 of
LT -> fromIntegral i -- median is duplicated, don't need average
GT -> findMid2 (succ i) (numRemaining - countOf (succ i))
EQ -> midAverage (succ i) (countOf (succ i))
where
midAverage j 0 = midAverage (succ j) (countOf (succ j))
midAverage j _ = averageValue (fromIntegral i) (fromIntegral j)
Note o uso da extensão do GHC BangPatterns
, coberta em um
dia de extensões do GHC de 2014,
para nos assegurarmos da avaliação estrita em loops recursivos “pela cauda”
(tail-recursive loops).
Formas mutáveis e imutáveis de criar a tabela de contagens
O que é makeCountsMutably
?
Mutável
A versão da função que usa mutação usa a
função create
:
-- | Use local mutation for efficiency in creating a table of counts,
-- looping through to update it, and freezing the result to return.
-- | Use mutabilidade local para eficiência em criar uma tabela de contagens,
-- fazendo um laço para a atualizar e congelando o valor retornado.
makeCountsMutably
:: V.Vector Value -- ^ values seen
-> V.Vector CountOfValue -- ^ value => count
makeCountsMutably values = V.create $ do
counts <- M.replicate numPossibleValues 0
V.forM_ values $
M.unsafeModify counts succ . fromIntegral
return counts
-- | 256, no nosso caso.
numPossibleValues :: Int
numPossibleValues = fromIntegral (maxBound :: Value) + 1
create
tem tipo:
create :: Unbox a => (forall s. ST s (MVector s a)) -> Vector a
E inteligentemente “congela” o vetor retornado no lugar, dentro do contexto do
monad ST
, em um vetor imutável, o que evita qualquer cópia, porque a mutação
acontece dentro do callback e não é visível fora dele.
Imutável
Mas espere, também há uma forma de criar a tabela usando somente Vector
s
imutáveis, sem nenhum vetor MVector
intermediário!
-- | Crie a tabela de contagens sem usar 'MVector'.
makeCountsPurely
:: V.Vector Value
-> V.Vector CountOfValue
makeCountsPurely =
V.unsafeAccumulate (+) (V.replicate numPossibleValues 0)
. V.map (\v -> (fromIntegral v, 1))
Isso é elegante de direto, mas parece criar um vetor intermediário usando
map
, que não é o que queremos já que todo o ponto é ter eficiência de espaço.
Fusion
Acontece que o vector
vem com sofisticado
suporte para fusion.
Um dia de Hackage posterior (agora disponível no
dia 19)
vou esplicar o que acontece nos bastidores. Efetivamente, o GHC reescreve as
chamadas para unsafeAccumulate
e map
para evitar o vetor intermediário!
Conclusão
Arrays de tamanho fixo são uma peça importante para a construção muitas
computações. Uso o vector
quando preciso fazer operações (sequenciais) sobre
arrays. Espero que esse pequeno exemplo mostre como escrever código imperativo
baseado em vetores em Haskell, escondendo a maior quantidade de estado
possível, por meio de APIs orientadas a tipos que operam entre os dois mundos
da mutabilidade e imutabilidade. É até possível escrever código que dependa no
fusion e não crie vetores mutáveis intermediários.
(Esses são arrays sequenciais. Para arrays paralelos, veja um
dia de Hackage de 2013,
que cobriu o repa
, uma biblioteca
para “arrays paralelos de alta performance, regulares, multi-dimensionais e
polimórficos”.)
Todo o código
Todo o código para a série estará nesse repositório do GitHub.
Nota do tradutor
Se você quer ajudar com esse tipo de coisa, agora é a hora. Entre no
Slack ou no
IRC da HaskellBR e
contribua. Esse blog e outros projetos associados estão na
organização haskellbr
no GitHub e em
haskellbr.com/git.