
C#
é uma linguagem incrivelmente flexível. Nele, você pode escrever não apenas os aplicativos de back-end ou de desktop. Eu uso o C#
para trabalhar com dados científicos, que impõem certos requisitos às ferramentas disponíveis no idioma. Embora o netcore
a agenda (considerando que, após o netstandard2.0
maioria dos recursos de idiomas e tempo de execução não é netframework
para o netframework
), continuo trabalhando com projetos legados.
Neste artigo, analiso uma aplicação não óbvia (mas provavelmente desejada?) Do Span<T>
e a diferença entre a implementação do Span<T>
no netframework
e netcore
devido às especificidades do clr
.
Disclaimer 1Os trechos de código deste artigo não são de forma alguma destinados ao uso em projetos do mundo real.
A solução proposta para o problema (rebuscado?) É mais uma prova de conceito.
De qualquer forma, ao implementá-lo em seu projeto, você faz isso por seu próprio risco e risco.
Isenção 2Estou absolutamente certo de que em algum lugar, em alguns casos, isso definitivamente matará alguém no joelho.
C#
improvável que o desvio de segurança de tipo em C#
leve a algo bom.
Por razões óbvias, não testei esse código em todas as situações possíveis; no entanto, os resultados preliminares parecem promissores.
Por que eu preciso do Span<T>
?
O Spen permite que você trabalhe com matrizes de tipos unmanaged
uma forma mais conveniente, reduzindo o número de alocações necessárias. Apesar do suporte à extensão na estrutura de netframework
BCL
netframework
quase completamente ausente, várias ferramentas podem ser obtidas usando System.Memory
, System.Buffers
e System.Runtime.CompilerServices.Unsafe
.
O uso de vãos no meu projeto legado é limitado, no entanto, achei um uso não óbvio, enquanto cuspia na segurança de tipos.
O que é esta aplicação? No meu projeto, trabalho com dados obtidos de uma ferramenta científica. São imagens, que, em geral, são uma matriz de T[]
, onde T
é um dos tipos primitivos unmanaged
, por exemplo, Int32
(também conhecido como int
). Para serializar corretamente essas imagens em disco, preciso oferecer suporte ao formato legado incrivelmente inconveniente, proposto em 1981 e que pouco mudou desde então. O principal problema desse formato é o BigEndian . Portanto, para escrever (ou ler) uma matriz não compactada de T[]
, você precisa alterar a endianess de cada elemento. A tarefa trivial.
Quais são algumas soluções óbvias?
- Nós iteramos sobre a matriz
T[]
, chamamos BitConverter.GetBytes(T)
, expandimos esses poucos bytes, BitConverter.GetBytes(T)
para a matriz de destino. - Nós iteramos sobre a matriz
T[]
, executamos fraudes no formato new byte[] {(byte)((x & 0xFF00) >> 8), (byte)(x & 0x00FF)};
(deve funcionar em tipos de byte duplo), escreva na matriz de destino. - * Mas
T[]
uma matriz? Os elementos estão em uma fileira, certo? Assim, você pode percorrer todo o caminho, por exemplo, Buffer.BlockCopy(intArray, 0, byteArray, 0, intArray.Length * sizeof(int));
. O método copia a matriz para a verificação de tipo de matriz ignorada. Só é necessário não perder os limites e a alocação. Nós misturamos os bytes como resultado. - * Eles dizem que
C#
é (C++)++
. Portanto, habilite /unsafe
, fixed(int* p = &intArr[0]) byte* bPtr = (byte*)p;
e agora você pode executar em torno da representação de bytes da matriz de origem, alterar endianess rapidamente e gravar blocos no disco (adicionando stackalloc byte[]
ou ArrayPool<byte>.Shared
pelo buffer intermediário) sem alocar memória para uma matriz de bytes totalmente nova.
Parece que o ponto 4 permite resolver todos os problemas, mas o uso explícito de contexto unsafe
e o trabalho com ponteiros é de alguma forma completamente diferente. Então Span<T>
vem em nosso auxílio.
Span<T>
Span<T>
tecnicamente deve fornecer ferramentas para trabalhar com plotagens de memória quase como trabalhar com ponteiros, eliminando a necessidade de "consertar" a matriz na memória. Esse ponteiro de reconhecimento de GC
com limites de matriz. Está tudo bem e seguro.
Uma coisa, mas - apesar da riqueza de System.Runtime.CompilerServices.Unsafe
, Span<T>
pregado no tipo T
Dado que o spen é essencialmente um ponteiro de 1 + comprimento, e se você puxar o ponteiro, convertê-lo em outro tipo, recalcular o comprimento e fazer uma nova extensão? Felizmente, temos public Span<T>(void* pointer, int length)
.
Vamos escrever um teste simples:
[Test] public void Test() { void Flip(Span<byte> span) {} Span<int> x = new [] {123}; Span<byte> y = DangerousCast<int, byte>(x); Assert.AreEqual(123, x[0]); Flip(y); Assert.AreNotEqual(123, x[0]); Flip(y); Assert.AreEqual(123, x[0]); }
Desenvolvedores mais avançados do que eu deveria perceber imediatamente o que está errado aqui. O teste falhará? A resposta, como geralmente acontece, depende .
Nesse caso, depende principalmente do tempo de execução. No netcore
teste deve funcionar, mas no netframework
, como netframework
.
Curiosamente, se você remover alguns dos ensaios, o teste começará a funcionar corretamente em 100% dos casos.
Vamos acertar.
1 eu estava errado .
Resposta correta: depende
Por que o resultado depende ?
Vamos remover todos os desnecessários e escrever aqui esse código:
private static void Main() => Check(); private static void Check() { Span<int> x = new[] {999, 123, 11, -100}; Span<byte> y = As<int, byte>(ref x); Console.WriteLine(@"FRAMEWORK_NAME"); Write(ref x); Write(ref y); Console.WriteLine(); Write<int, int>(ref x, "Span<int> [0]"); Write<byte, int>(ref y, "Span<byte>[0]"); Console.WriteLine(); Write<int, int>(ref Offset<int, object>(ref x[0], 1), "Span<int> [0] offset by size_t"); Write<byte, int>(ref Offset<byte, object>(ref y[0], 1), "Span<byte>[0] offset by size_t"); Console.WriteLine(); GC.Collect(0, GCCollectionMode.Forced, true, true); Write<int, int>(ref x, "Span<int> [0] after GC"); Write<byte, int>(ref y, "Span<byte>[0] after GC"); Console.WriteLine(); Write(ref x); Write(ref y); }
O método Write<T, U>
aceita uma extensão do tipo T
, lê o endereço do primeiro elemento e lê nesse ponteiro um elemento do tipo U
Em outras palavras, Write<int, int>(ref x)
produzirá o endereço na memória + o número 999.
Write
normal imprime uma matriz.
Agora, sobre o método As<,>
:
private static unsafe Span<U> As<T, U>(ref Span<T> span) where T : unmanaged where U : unmanaged { fixed(T* ptr = span) return new Span<U>(ptr, span.Length * Unsafe.SizeOf<T>() / Unsafe.SizeOf<U>()); }
C#
sintaxe C#
agora suporta esse registro de estado fixed
chamando implicitamente o método Span<T>.GetPinnableReference()
.
Execute este método no netframework4.8
no modo x64
. Nós olhamos o que acontece:
LEGACY [ 999, 123, 11, -100 ] [ 231, 3, 0, 0, 123, 0, 0, 0, 11, 0, 0, 0, 156, 255, 255, 255 ] 0x|00|00|02|8C|00|00|2F|B0 999 Span<int> [0] 0x|00|00|02|8C|00|00|2F|B0 999 Span<byte>[0] 0x|00|00|02|8C|00|00|2F|B8 11 Span<int> [0] offset by size_t 0x|00|00|02|8C|00|00|2F|B8 11 Span<byte>[0] offset by size_t 0x|00|00|02|8C|00|00|2B|18 999 Span<int> [0] after GC 0x|00|00|02|8C|00|00|2F|B0 6750318 Span<byte>[0] after GC [ 999, 123, 11, -100 ] [ 110, 0, 103, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ]
Inicialmente, os dois intervalos (apesar dos diferentes tipos) se comportam de forma idêntica, e o Span<byte>
, em essência, representa uma visualização em bytes da matriz original. O que você precisa
Ok, vamos tentar mudar o início do período para o tamanho de um IntPtr
(ou 2 X int
em x64
) e ler. Obtemos o terceiro elemento da matriz e o endereço correto. E depois vamos recolher o lixo ...
GC.Collect(0, GCCollectionMode.Forced, true, true);
O último sinalizador neste método pede ao GC
compactar o heap. Depois de chamar GC.Collect
GC
move a matriz local original. Span<int>
reflete essas alterações, mas nosso Span<byte>
continua apontando para o endereço antigo, onde agora não está claro o que. Uma ótima maneira de atirar em todos os joelhos de uma só vez!
Agora, vamos ver o resultado do mesmo fragmento de código chamado netcore3.0.100-preview8
.
CORE [ 999, 123, 11, -100 ] [ 231, 3, 0, 0, 123, 0, 0, 0, 11, 0, 0, 0, 156, 255, 255, 255 ] 0x|00|00|01|F2|8F|BD|C6|90 999 Span<int> [0] 0x|00|00|01|F2|8F|BD|C6|90 999 Span<byte>[0] 0x|00|00|01|F2|8F|BD|C6|98 11 Span<int> [0] offset by size_t 0x|00|00|01|F2|8F|BD|C6|98 11 Span<byte>[0] offset by size_t 0x|00|00|01|F2|8F|BD|BF|38 999 Span<int> [0] after GC 0x|00|00|01|F2|8F|BD|BF|38 999 Span<byte>[0] after GC [ 999, 123, 11, -100 ] [ 231, 3, 0, 0, 123, 0, 0, 0, 11, 0, 0, 0, 156, 255, 255, 255 ]
Tudo funciona, e funciona de forma estável , tanto quanto eu posso ver. Após a compactação, ambos os spains mudam de ponteiro. Ótimo! Mas como agora fazê-lo funcionar em um projeto herdado?
Jit intrínseco
Eu absolutamente esqueci que o suporte a extensões é implementado no netcore
através do intrinsik . Em outras palavras, o netcore
pode criar ponteiros internos até para um fragmento de matriz e atualizar corretamente os links quando o GC
move. No netframework
, a implementação de nuget
de um span é uma muleta. De fato, temos dois spens diferentes: um é criado a partir da matriz e rastreia seus links, o segundo a partir do ponteiro e não faz ideia do que ele aponta. Depois de mover a matriz original, o ponteiro de amplitude continua a apontar para onde o ponteiro passou para o construtor apontado. Para comparação, este é um exemplo de implementação de span no netcore
:
readonly ref struct Span<T> where T : unmanaged { private readonly ByReference<T> _pointer;
e na netframework
:
readonly ref struct Span<T> where T : unmanaged { private readonly Pinnable<T> _pinnable; private readonly IntPtr _byteOffset; private readonly int _length; }
_pinnable
contém uma referência à matriz, se uma foi passada ao construtor, _byteOffset
contém uma mudança (mesmo a extensão em toda a matriz tem alguma mudança diferente de zero relacionada à maneira como a matriz é representada na memória, provavelmente ). Se você passar o ponteiro void*
para o construtor, ele é simplesmente convertido em um absoluto _byteOffset
. A extensão será pregada firmemente na área da memória e todos os métodos de instância abundam com condições como if(_pinnable is null) {/* */} else {/* _pinnable */}
. O que fazer em tal situação?
Como fazer isso não vale a pena, mas eu ainda fiz
Esta seção é dedicada a várias implementações suportadas pelo netframework
, que permitem netframework
Span<T> -> Span<U>
, mantendo todos os links necessários.
Eu aviso: esta é uma zona de programação anormal com possíveis erros fundamentais e um comportamento indefinido no final
Método 1: Ingênuo
Como o exemplo mostrou, a conversão de ponteiros não fornecerá o resultado desejado na netframework
da netframework
. Precisamos do valor _pinnable
. Ok, descobriremos o reflexo retirando os campos particulares (muito ruins e nem sempre possíveis), escreveremos em um novo espaço, seremos felizes. Há apenas um pequeno problema: spen é uma ref struct
, não pode ser um argumento genérico, nem pode ser empacotado em um object
. Os métodos padrão de reflexão exigirão, de uma maneira ou de outra, empurrar a extensão para o tipo de referência. Não encontrei uma maneira simples (mesmo considerando a reflexão em campos particulares).
Método 2: precisamos ir mais fundo
Tudo já foi feito antes de mim ( [1] , [2] , [3] ). Spen é uma estrutura, independentemente de T
três campos ocupam a mesma quantidade de memória ( na mesma arquitetura ). E se [FieldOffset(0)]
? Mal disse o que fez.
[StructLayout(LayoutKind.Explicit)] ref struct Exchange<T, U> where T : unmanaged where U : unmanaged { [FieldOffset(0)] public Span<T> Span_1; [FieldOffset(0)] public Span<U> Span_2; }
Mas quando você inicia o programa (ou melhor, ao tentar usar um tipo), uma TypeLoadException
encontra - um genérico não pode ser LayoutKind.Explicit
. Ok, não importa, vamos seguir o caminho difícil:
[StructLayout(LayoutKind.Explicit)] public ref struct Exchange { [FieldOffset(0)] public Span<byte> ByteSpan; [FieldOffset(0)] public Span<sbyte> SByteSpan; [FieldOffset(0)] public Span<ushort> UShortSpan; [FieldOffset(0)] public Span<short> ShortSpan; [FieldOffset(0)] public Span<uint> UIntSpan; [FieldOffset(0)] public Span<int> IntSpan; [FieldOffset(0)] public Span<ulong> ULongSpan; [FieldOffset(0)] public Span<long> LongSpan; [FieldOffset(0)] public Span<float> FloatSpan; [FieldOffset(0)] public Span<double> DoubleSpan; [FieldOffset(0)] public Span<char> CharSpan; }
Agora você pode fazer isso:
private static Span<byte> As2(Span<int> span) { var exchange = new Exchange() { IntSpan = span }; return exchange.ByteSpan; }
O método funciona com apenas um problema - o campo _length
copiado como está; portanto, ao _length
int
-> byte
a extensão de bytes é 4 vezes menor que a matriz real.
Não é um problema:
[StructLayout(LayoutKind.Sequential)] public ref struct Raw { public object Pinnable; public IntPtr Pointer; public int Length; } [StructLayout(LayoutKind.Explicit)] public ref struct Exchange { [FieldOffset(0)] public Raw RawView; }
Agora, através do RawView
você pode acessar cada campo de amplitude individual.
private static Span<byte> As2(Span<int> span) { var exchange = new Exchange() { IntSpan = span }; var exchange2 = new Exchange() { RawView = new Raw() { Pinnable = exchange.RawView.Pinnable, Pointer = exchange.RawView.Pointer, Length = exchange.RawView.Length * sizeof<int> / sizeof<byte> } }; return exchange2.ByteSpan; }
E funciona como deveria , se você ignorar o uso de truques sujos. Menos - a versão genérica do conversor não pode ser criada; você precisa se contentar com tipos predefinidos.
Método 3: Louco
Como qualquer programador normal, eu gosto de automatizar as coisas. A necessidade de escrever conversores para qualquer par de tipos não unmanaged
não me agradou. Que solução pode ser oferecida? É isso mesmo, faça o CLR
escrever o código para você .
Como conseguir isso? Existem maneiras diferentes, existem artigos . Em suma, o processo é assim:
Crie um construtor de construção -> crie um construtor de módulo -> crie um tipo -> {Fields, Methods, etc.} -> na saída, obtemos uma instância do Type
.
Para entender exatamente como deve ser o tipo (é uma ref struct
), usamos qualquer ferramenta do tipo ildasm
. No meu caso, foi o dotPeek .
Criar um construtor de tipos é algo como isto:
var typeBuilder = _mBuilder.DefineType($"Generated_{typeof(T).Name}", TypeAttributes.Public | TypeAttributes.Sealed | TypeAttributes.ExplicitLayout
Agora os campos. Como não podemos copiar diretamente Span<T>
para Span<U>
devido à diferença de comprimentos, precisamos criar dois tipos de elenco
[StructLayout(LayoutKind.Explicit)] ref struct Generated_Int32 { [FieldOffset(0)] public Span<Int32> Span; [FieldOffset(0)] public Raw Raw; }
Aqui Raw
podemos declarar com nossas mãos e reutilizar. Não se esqueça de IsByRefLikeAttribute
. Com campos, tudo é simples:
var spanField = typeBuilder.DefineField("Span", typeof(Span<T>), FieldAttributes.Private); spanField.SetOffset(0); var rawField = typeBuilder.DefineField("Raw", typeof(Raw), FieldAttributes.Private); rawField.SetOffset(0);
Isso é tudo, o tipo mais simples está pronto. Agora, armazene em cache o módulo de montagem. Os tipos personalizados são armazenados em cache, por exemplo, no dicionário ( T -> Generated_{nameof(T)}
). Criamos um invólucro que, de acordo com os dois tipos TIn
e TOut
gera dois tipos de auxiliares e executa as operações necessárias nos vãos. Existe um mas. Como no caso da reflexão, é quase impossível usá-lo em vãos (ou em outras ref struct
). Ou não encontrei uma solução simples . Como ser
Delegados para o resgate
Os métodos de reflexão geralmente se parecem com isso:
object Invoke(this MethodInfo mi, object @this, object[] otherArgs)
Eles não carregam informações sobre os tipos; portanto, se o boxe (= embalagem) for aceitável para você, não haverá problemas.
No nosso caso, @this
e otherArgs
devem conter uma ref struct
, que eu não consegui contornar.
No entanto, existe uma maneira mais simples. Vamos imaginar que um tipo tenha métodos getter e setter (não propriedades, mas métodos simples criados manualmente).
Por exemplo:
void Generated_Int32.SetSpan(Span<Int32> span) => this.Span = span;
Além do método, podemos declarar um tipo de delegado (explicitamente no código):
delegate void SpanSetterDelegate<T>(Span<T> span) where T : unmanaged;
Temos que fazer isso porque a ação padrão teria que ter uma assinatura Action<Span<T>>
, mas spenes não podem ser usados como argumentos genéricos. SpanSetterDelegate
, no entanto, é um representante absolutamente válido.
Crie os delegados necessários. Para fazer isso, execute manipulações padrão:
var mi = type.GetMethod("Method_Name");
Agora, spanSetter
pode ser usado como, por exemplo, spanSetter(Span<T>.Empty);
. Quanto a @this
2 , essa é uma instância do nosso tipo dinâmico, criada, é claro, por meio de Activator.CreateInstance(type)
, porque a estrutura possui um construtor padrão sem argumentos.
Portanto, a última fronteira - precisamos gerar métodos dinamicamente.
2 Você pode perceber que algo está errado aqui - Activator.CreateInstance()
compactando uma instância de ref struct
. Veja o final da próxima seção.
Conheça Reflection.Emit
Eu acho que os métodos podem ser gerados usando Expression
, como os corpos de nossos getters / setters triviais consistem literalmente em algumas expressões. Eu escolhi uma abordagem diferente e mais direta.
Se você olhar para o código IL de um getter trivial, poderá ver algo como ( Debug
, X86
, netframework4.8
)
nop ldarg.0 ldfld /* - */ stloc.0 br.s /* */ ldloc.0 ret
Existem muitos lugares para parar e depurar.
Na versão de lançamento, apenas o mais importante permanece:
ldarg.0 ldfld /* - */ ret
O argumento nulo do método de instância é ... this
. Assim, o seguinte está escrito em IL :
1) Faça this
download this
2) Carregue o valor do campo
3) Traga de volta
Apenas hein? Reflection.Emit
tem uma sobrecarga especial que leva, além do código op, também um parâmetro do descritor de campo. Da mesma forma que recebemos anteriormente, por exemplo, spanField
.
var getSpan = type.DefineMethod("GetSpan", MethodAttributes.Public | MethodAttributes.HideBySig, CallingConventions.Standard, typeof(Span<T>), Array.Empty<Type>()); gen = getSpan.GetILGenerator(); gen.Emit(OpCodes.Ldarg_0); gen.Emit(OpCodes.Ldfld, spanField); gen.Emit(OpCodes.Ret);
Para o setter, é um pouco mais complicado, você precisa carregar isso na pilha, carregar o primeiro argumento da função, chamar a instrução write no campo e não retornar nada:
ldarg.0 ldarg.1 stfld ret
Depois de executar esse procedimento no campo Raw
, declarando os delegados necessários (ou usando os padrão), obtemos um tipo dinâmico e quatro métodos de acesso, a partir dos quais os delegados genéricos corretos são gerados.
Escrevemos uma classe de wrapper que, usando dois parâmetros genéricos ( TIn
, TOut
), recebe instâncias do tipo Type
que fazem referência aos tipos dinâmicos correspondentes (em cache), após o qual cria um objeto de cada tipo e gera quatro delegados genéricos, a saber
void SetSpan(Span<TIn> span)
para gravar o span de origem na estruturaRaw GetRaw()
para ler o conteúdo de um intervalo como uma estrutura Raw
void SetRaw(Raw raw)
para gravar a estrutura Raw
modificada no segundo objetoSpan<TOut> GetSpan()
para retornar o span do tipo desejado com os campos corretamente definidos e recalculados.
Curiosamente, as instâncias de tipo dinâmico precisam ser criadas uma vez. Ao criar um delegado, uma referência a esses objetos é passada como um parâmetro @this
. Aqui está uma violação das regras. Activator.CreateInstance
retorna o object
. Aparentemente, isso se deve ao fato de que o próprio tipo dinâmico não type.IsByRef
ref
like ( type.IsByRef
Like == false
), mas foi possível criar campos semelhantes a ref
. Aparentemente, essa restrição está presente no idioma, mas o CLR
digere. Talvez seja aqui que os joelhos serão baleados no caso de uso fora do padrão. 3
Portanto, obtemos uma instância de um tipo genérico que contém quatro delegados e duas referências implícitas a instâncias de classes dinâmicas. Delegados e estruturas podem ser reutilizados ao executar as mesmas castas em uma linha. Para melhorar o desempenho, armazenamos em cache novamente (já um conversor de tipos) para um par (TIn, TOut) -> Generator<TIn, TOut>
.
O golpe é o último: damos tipos, Span<TIn> -> Span<TOut>
public Span<TOut> Cast(Span<TIn> span) {
Conclusão
Às vezes - por uma questão de interesse esportivo - você pode ignorar algumas das limitações do idioma e implementar funcionalidades não padrão. Claro, por sua conta e risco. Vale ressaltar que o método dinâmico permite abandonar completamente ponteiros e contextos unsafe / fixed
, o que pode ser um bônus. A desvantagem óbvia é a necessidade de reflexão e geração de tipos.
Para quem leu até o fim.
Resultados ingênuos de referênciaE quão rápido é tudo isso?
Comparei a velocidade das castas em um cenário estúpido que não reflete o uso real / potencial dessas castas e extensões, mas pelo menos dá uma idéia da velocidade.
Cast_Explicit
, 2 . ;Cast_IL
3 , Generator<TIn, TOut>
, , ;Cast_IL_Cached
Generator<TIn, TOut>
, - , .. ;Buffer
, , . .
— int[N]
N/2
.
, , . , . , , . , unmanaged
.
BenchmarkDotNet=v0.11.5, OS=Windows 10.0.18362 Intel Core i7-2700K CPU 3.50GHz (Sandy Bridge), 1 CPU, 8 logical and 4 physical cores [Host] : .NET Framework 4.7.2 (CLR 4.0.30319.42000), 32bit LegacyJIT-v4.8.3815.0 Clr : .NET Framework 4.7.2 (CLR 4.0.30319.42000), 32bit LegacyJIT-v4.8.3815.0 Job=Clr Runtime=Clr InvocationCount=1 UnrollFactor=1
PS
3 , ref
, , . ( ) . ref
structs,
static Raw Generated_Int32.GetRaw(Span<int> span) { var inst = new Generated_Int32() { Span = span }; return inst.Raw; }
, Reflection.Emit
. , ILGenerator.DeclareLocal
.
static Span<int> Generated_Int32.GetSpan(Raw raw);
delegate Raw GetRaw<T>(Span<T> span) where T : unmanaged; delegate Span<T> GetSpan<T>(Raw raw) where T : unmanaged;
, , ref
— . Porque ,
var getter = type.GetMethod(@"GetRaw", BindingFlags.Static | BindingFlags.Public).CreateDelegate(typeof(GetRaw<T>), null) as GetRaw<T>;
—
Raw raw = getter(Span<TIn>.Empty); Raw newRaw = convert(raw); Span<TOut> = setter(newRaw);
UPD01: