Categorías
C#

La la, how their life goes on

Si insisto tanto en estos temas de instrucciones vectoriales, es parar preparar a mis lectores potenciales para que usen estas técnicas cuando lo crean necesario. Naturalmente, las primeras explicaciones van a ser del tipo heroico: todo hay que hacerlo a mano, razonando desde los primeros principios. Pero, como decía Molly Jones, la mujer de Desmond Jones, la la, how their life goes on: la vida sigue su curso, y lo normal en la vida de un programador es usar las técnicas que todos conocemos y apreciamos para ahorrarnos esfuerzo.

Lo primero para que todos nos ahorremos trabajo, por supuesto, consiste en usar estas cosas a través de una librería bien probada. Ya existen unas cuantas de estas, pero estoy creando Austra porque hay un nicho muy concreto, y porque la librería tiene méritos propios. La idea es hacer de Austra un proyecto open source, por supuesto. En este momento está en GitHub, pero no es por ahora un repositorio público porque la aplicación de «pruebas» está basada en WPF y DevExpress. O me compro personalmente una licencia comercial, o cambio los controles por algo gratuito. No es sencillo. Hay cosas como Avalonia o Uno Platform, que además, son multiplataformas, pero son bastante pobres en componentes. Y no quiero ni oír hablar de .NET MAUI: es un fracaso, de momento. Antes que usar .NET MAUI, prefiero «degradar» la aplicación a Windows Forms (tengo un editor de código bastante bueno) y currarme los gráficos y las cosas que falten. Pero a eso vamos: a que Austra esté disponible gratuitamente como open source, que cualquier que quiera pueda colaborar, que haya un conjunto de tests y benchmarks exhaustivo, etc, etc.

Pero no se escribe un post para explicar lo obvio. Mi verdadero objetivo ahora es explicar un par de técnicas muy tontas que bajan el listón del heroísmo al escribir este tipo de código, y que todos conocemos, pero que nos puede dar miedo usar a primeras, porque no sabes cómo va a interferir en la generación de código de .NET 7 y .NET 8 (que tiene sus cagadas, como todo).

Primer ejemplo: resulta que mucho de estos algoritmos algebraicos calculan el producto escalar de dos zonas de memoria en muchos casos. Hay una clase Vector con su correspondiente producto escalar, e incluso un ComplexVector, pero es mejor tener una rutina que maneje directamente zonas de memoria con un tamaño predeterminado. Austra tiene una clase estática CommonMatrix que precisamente implementa estas cosas. Ésta es la última versión del método en cuestión:

/// <summary>
/// Computes the dot product of two double arrays.
/// </summary>
/// <param name="p">Pointer to the first array.</param>
/// <param name="q">Pointer to the second array.</param>
/// <param name="size">Number of items in each array.</param>
/// <returns>A sum of products.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public unsafe static double DotProduct(
    double* p, double* q, int size)
{
    double sum;
    int i = 0;
    if (Avx.IsSupported)
    {
        Vector256<double> acc = Vector256<double>.Zero;
        for (int top = size & AVX_MASK; i < top; i += 4)
            acc = acc.MultiplyAdd(p + i, q + i);
        sum = acc.Sum();
    }
    else
        sum = 0;
    for (; i < size; i++)
        sum += p[i] * q[i];
    return sum;
}

A primera vista, lo único raro aquí es el atributo que fuerza un inlining agresivo. Pero hay un par de cosillas más que no son métodos habituales de AVX. Los muestro aparte aquí, porque son métodos de extensión de la misma clase CommonMatrix:

/// <summary>Sums all the elements in a vector.</summary>
/// <param name="v">
/// A intrinsics vector with four doubles.
/// </param>
/// <returns>The total value.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static double Sum(this Vector256<double> v)
{ 
    v = Avx.HorizontalAdd(v, v);
    return v.ToScalar() + v.GetElement(2);
}

Resulta que sumar los cuatro elementos de un vector de cuatro dobles no es moco de pavo. Hay teorías que pululan por la Internet sobre cuál es la forma eficiente de lograrlo. Yo no estoy seguro aún de que la mía sea la más eficiente, pero gracias al encapsulamiento en un método separado, si descubro algo mejor, tendré que tocar el código en un único lugar. Obvio, ¿no? Pero hasta comprobar que el JIT de .NET Core no se volvía loco, no me atreví a dar este paso. Ya he comprobado que no pasan cosas raras.

Es curioso, sin embargo, que el mínimo y el máximo de un vector se calcule más eficientemente con un método como el siguiente:

[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static double Max(this Vector256<double> v)
{
    var x = Sse2.Max(v.GetLower(), v.GetUpper());
    return Math.Max(x.ToScalar(), x.GetElement(1));
}

En este caso, no parece que la transición temporal a una instrucción SSE haga mucho daño al código que la rodea. De todas maneras, observe que para la reducción final al escalar hay que ir a por el segundo elemento directamente. Cosas de Intel.

Éste es otro método que usa el código original, omitiendo esta vez los comentarios XML:

internal unsafe static Vector256<double> MultiplyAdd(
    this Vector256<double> summand,
    double* multiplicand,
    double* multiplier) =>
    Fma.IsSupported
        ? Fma.MultiplyAdd(
            Avx.LoadVector256(multiplicand),
            Avx.LoadVector256(multiplier),
            summand)
        : Avx.Add(summand, Avx.Multiply(
            Avx.LoadVector256(multiplicand),
            Avx.LoadVector256(multiplier)));

Hay un par de variantes más que usan vectores directamente en vez de direcciones, y variantes adicionales para MultiplySubtract y MultiplyAddNegated, que sons métodos FMA. Estas aparentes tonterías ahorran líneas y líneas de código. En mi caso, como tengo memoria fotográfica, prefiero que el código fuente sea lo más pequeño posible para poder recordarlo más adelante.

Categorías
C#

Reuniendo piezas de un vector

Cuando hay que utilizar instrucciones vectoriales, es siempre aconsejable que se cargue en memoria un trozo consecutivo de memoria para crear vectores. Para un programador ya entrenado en AVX/AVX2/AVX512, por ejemplo, es fácil darse cuenta de que el siguiente bucle (un producto escalar de dos vectores) es un candidato ideal para usar instrucciones SIMD:

double sum = 0.0;
for (int i = 0; i < size; i++)
    sum += p[i] * q[i];

Hay dos zonas de memoria, p y q, y el índice se mueve ascendentemente de uno en uno, en ambos casos. Por lo tanto, para convertir este código en instrucciones vectoriales de AVX, hacemos que cada paso del código cargue cuatro entradas de cada vector, en cada paso del bucle:

double sum = 0;
int i = 0;
if (Fma.IsSupported)
{
    var acc = Vector256<double>.Zero;
    for (; i < size - 4; i += 4)
        acc = Fma.MultiplyAdd(
            Avx.LoadVector256(p + i),
            Avx.LoadVector256(q + i),
            acc);
    acc = Avx.HorizontalAdd(acc, acc);
    sum = acc.ToScalar() + acc.GetElement(2);
}
for (; i < size; i++)
    sum += p[i] * q[i];

Gracias a este sencillo truco, se aprovecha mejor la caché de memoria del procesador. Es cierto que p y q pueden estar en direcciones distantes, pero al menos la carga de cada trozo de cuatro valores no provoca invalidaciones de la caché. En cambio, este otro fragmento de código, extraído del código de Austra que realiza la descomposición de una matriz según la factorización LU, no cumple con esta regla:

double m = c[k];
for (int i = k + 1; i < size; i++)
    c[i] -= m * a[i * size + k];

El almacenamiento en c es consecutivo, pero si reunimos cuatro pasos del bucle, las cargas desde el vector a van a venir de zonas dispersas de la memoria: esto es porque la variable de bucle en el indexador está multiplicada por size. Sería una pena no poder utilizar AVX aquí, porque podríamos reducir por cuatro las restas y multiplicaciones del bucle.

Por fortuna, Intel agregó al conjunto de instrucciones de AVX2 una instrucción que, precisamente, sirve para reunir en una misma carga fragmentos de memoria no consecutivos. Se trata de la instrucción vgatherdpd, que .NET implementa mediante el método Avx2.GatherVector:

public static Vector256<double> GatherVector256(
    double* baseAddress,
    Vector128<int> index,
    byte scale);

A primera vista, parece arameo antiguo. A segunda y tercera vistas, se asemeja más al sumerio de chabolas. Pero no es tan complicado explicar cómo funciona, sobre todo si lo acompañamos con un ejemplo. A este método le pasamos un puntero a un array de números de doble precisión, esa es la parte obvia. Además, le pasamos cuatro índices de tipo entero, en el parámetro de tipo Vector128<int>. Como este tipo contiene 128 bits, es evidente que ahí podemos pasar cuatro enteros de 32 bits; ya veremos cómo. ¿Y el factor de escala? Aquí es un poco redundante, y Microsoft podría habernos ahorrado el trabajo: cuando se trabaja con vectores de double, tenemos que pasar un 8, o lo que es igual, sizeof(double).

Juntando las piezas, he aquí como quedaría el código transformado del fragmento original:

double m = c[k];
int i = k + 1;
if (Avx2.IsSupported)
{
    var vm = Vector256.Create(m);
    var vx = Vector128.Create(0, size, 2 * size, 3 * size);
    for (double* p = a + i * size + k;
        i < size - 4;
        i += 4, p += size * 4)
        Avx.Store(c + i,
            Avx.Subtract(Avx.LoadVector256(c + i),
            Avx.Multiply(Avx2.GatherVector256(p, vx, 8), vm)));
}
for (; i < size; i++)
    c[i] -= m * a[i * size + k];

Expliquémonos: antes de entrar en el bucle, creamos un vector de números reales a partir del valor de la variable m. Lo que sigue es lo que nos interesa: creamos por adelantado, un Vector128<int> con cuatro offsets o desplazamientos diferentes, múltiplos del tamaño de la fila de la matriz, que viene en size. Y ahora viene el detalle artesanal delicado: al iniciar el bucle, creo una variable adicional de puntero, p, que apunte i * size + k entradas a partir de la dirección inicial, que está en a. Recuerde que leíamos el valor de a[i * size + k] en cada paso del bucle original, y que i es la variable de bucle. Si hubiese querido ser más claro, debería haber escrito:

double* p = a + (k + 1) * size + k

Pero me puede la pereza, y al fin y al cabo, en ese preciso instante, i es equivalente a k + 1, ¿no?

El detalle es que, en cada paso del bucle AVX, la variable p se incrementa en 4 * size entradas (es decir, en cuatro filas enteras de size entradas). Digo que es un detallazo elegante porque la alternativa obvia sería incrementar directamente los cuatro índices enteros que guardamos en el vector vx. ¿Por qué no lo he hecho así? Pues porque, al trabajar con vectores de 128 bits, la instrucción de suma pertenecería al conjunto de instrucciones SSE2… y resulta que el cambio entre AVX y SSE tiene un coste pequeño, pero no desdeñable.

… y este es el momento más apropiado para exclamar ¡pero coño! (con perdón), ¿esto no tendría que hacerlo el compilador, o el JIT, o quien narices sea?. Pues sí. De hecho, teóricamente, Hotspot, el JIT de Java, tendría que ocuparse de estas cosas. En la práctica, la vectorización automática en Hotspot es un truño como un puño. Y el famoso escape analysis tampoco hace lo que se supone que tendría que hacer. O si lo hace, no se nota. Al final, Java ha añadido una librería de vectores basados en instrucciones SIMD, al estilo de .NET. Los compiladores de C/C++ hacen mejor papel en estos casos, pero no son omnipotentes, y a veces tienes que echarles una mano.

NOTA: AVX2, creo recordar, se introdujo en la cuarta generación de Intel, Haswell, en 2013. La primera implementación en hardware de vgatherdpd no era para tirar cohetes, y todavía hay páginas y comentarios en Internet que lo recuerdan. La implementación mejoró notablemente en Skylake, la sexta generación (2015). Yo tuve un Haswell durante muchos años, y empecé a probar AVX2 con esa máquina. Mi penúltimo portátil fue un Skylake. Ahora mismo tengo un PC con CPU de oncena generación, Tiger Lake, que es un pepino. Lo menciono porque, por casualidad, Tiger Lake es la última generación de procesadores de «consumo» que incluye las instrucciones AVX512: con la llegada de las CPU con núcleos heterogéneos, estas instrucciones se deshabilitan porque los núcleos «eficientes» no las implementan. Ahora mismo, si quieres AVX512, tienes que irte a Xeon, o a AMD.

Categorías
Quantum

Quantum Drag & Drop

A pesar de haber escrito ya unas cuantas entradas sobre computación cuántica, todavía no he llegado al punto en que le explico cómo funciona un ordenador cuántico, y las reglas fundamentales para escribir algoritmos para estos futuros aparatos. Voy a aprovechar la pausa para hacerlo, y le adelanto que el asunto se las trae.

Voy a usar una metáfora muy mía, a la que llamo quantum drag & drop. La voy a explicar usando un ordenador imaginario con un solo qubit, pero es fácilmente extrapolable al número de qubits que se le ocurra. La simplificación sólo nos servirá para hacer más fácil la imaginación visual. Es sencillo visualizar una esfera de Bloch para un qubit. Las hiper-esferas de más de un qubit pueden que las visualice algún extraterrestre, pero que yo sepa, nadie me lee fuera de este planeta.

Inicialización, rotación y medición

La idea, básicamente, consiste en que todo algoritmo cuántico tiene tres partes: inicialización, rotación (o transformación) y medición. No hay nada nuevo en esto: lo hemos estando viendo desde el inicio de este blog.

Inicialización
Casi nadie piensa en la inicialización de verdad, porque el ordenador cuántico ya nos la da hecha. Pero hay que recordarlo de vez en cuando: el ordenador comienza con un estado cuántico bien determinado, que normalmente es el estado que se asocia a una cadena de ceros clásicos.
Transformación: superposición
La primera parte de la transformación, en la mayoría de los algoritmos útiles, consiste en transformar ese estado inicial a un estado que sea una superposición de estados de la base de medición. La transformación es una simple rotación del estado inicial. Estamos hablando de un espacio multidimensional complejo, pero las dichosas transformaciones no dejan de ser rotaciones.
Más transformaciones
… y a partir de ese momento, seguimos rotando el estado conseguido. Al final, no importa cuántas rotaciones hagamos: las rotaciones son un grupo algebraico, y la ejecución de dos de ellas es simplemente otra rotación.
¡Colapso!
Las rotaciones son matemáticamente bonitas y comprensibles, pero para terminar el algoritmo, hay que medir. Y para medir, hay que provocar el colapso del estado cuántico. Esto es muy feo. Hay tropecientas sectas entre los físicos, y no hablemos ya de filósofos, enfrentadas por cómo interpretan este rollo del colapso, o según la excusa que dan para ignorarlo. Este paso es el que proporciona la salida del algoritmo: una cadena de ceros y unos clásicos.

La metáfora visual

Necesitaríamos imaginar una hiper-esfera de Bloch, pero nos vamos a tener que apañar con la esfera de un qubit. No hay diferencias en el argumento básico, y si alguien descubre que me equivoco, que me lo diga inmediatamente, por favor.

El estado inicial que proporciona el ordenador es algo parecido a esto:

Estado inicial

La bolita azul que está en el polo norte marca en qué estado cuántico está el aparato. Como convenio, vamos a suponer que un cero «clásico» es el polo norte, y que el uno «clásico» es el polo sur. El estado solamente puede moverse sobre la superficie de la esfera, y todo lo que no sea el polo norte o el polo sur es un estado superpuesto.

¡Mucho cuidado con esto! En muchos libros, le explicarán que, en el caso de N-qubits, el estado en alguno de las esferas correspondientes a los qubits separados, puede estar dentro de la esfera. Pero eso ocurre solamente si consideramos los qubits por separados. Cuando se utiliza una hiper-esfera para el conjunto de qubits, el estado siempre estará en la superficie de la hiper-esfera, porque la única transformación posible es una rotación.

Tras la inicialización, en la que nosotros no intervenimos para nada, nuestro papel es agarrar la bolita de las narices y hacerla rodar sobre la esfera o hiper-esfera hasta el sitio que queramos, para que quede más o menos así:

¿Le extrañaría mucho si llamo «drag» a esta parte del algoritmo? A continuación, naturalmente, vendrá el «drop«: soltamos la bolita, y esta regresará a uno de sus estados «naturales» clásicos. En el caso de un un qubit, al polo norte o sur. En el caso que no podemos visualizar cómodamente de con N-qubits, a uno de los 2^N estados clásicos marcados en la superficie de la hiper-esfera. A cuál de ellos, exactamente, dependerá del azar, y de los pesos en el estado transformado de los estados de la base de medición. Dicho en otras palabras: si estábamos más cerca del polo norte que del sur, es más probable que veamos a Santa Claus que a un pingüino. Pero a no ser que estamos en el mismísimo polo norte, siempre hay la posibilidad de ver al pingüino.

Voilà, c’est tout!

Conclusiones inevitables

Hay mucha exageración ahora mismo con las capacidades de la computación cuántica. Esto no beneficia a nadie… excepto a algunos cazadores sin escrúpulos de subvenciones, que se aprovechan de la mano suelta de los políticos y del miedo de todos a «quedarse atrás».

No hay que caer en el extremo contrario: la computación cuántica tiene muchas aplicaciones… o las tendrá cuando el hardware esté realmente disponible. Pero el Office, la contabilidad de la empresa y el Spotify van a seguir funcionando sobre una CPU clásica.

Hay sólo tres o cuatro algoritmos cuánticos útiles, y llevamos veinte años en esta situación. Es difícil prever lo que el genio humano puede conseguir. Pero, por favor, no perdamos el contacto con la Realidad.

Categorías
C#

Multiplicar por la traspuesta

Una operación muy frecuente con matrices consiste en multiplicar una matriz por la traspuesta de otra. La implementación más sencilla de esto sería trasponer la segunda matriz y luego multiplicar la primera matriz por la traspuesta. Lamentablemente, trasponer una matriz es poco eficiente. No hay un patrón sencillo de acceso a la caché que sea bueno al mismo tiempo: hay que descomponer la matriz en trozos pequeños… en pocas palabras, un lío. Y resulta que hay una forma más sencilla de conseguirlo: invertir la posición de los índices al acceder a la segunda matriz. El código «naïve» para la multiplicación directa de dos matrices se resumía en un triple bucle:

for (int i = 0; i < m; i++)
    for (int j = 0; j < p; j++)
    {
        double d = 0;
        for (int k = 0; k < n; k++)
            d += a[i, k] * b[k, j];
        result[i, j] = d;
    }

En la entrada sobre multiplicación de matrices, invertíamos el orden de los dos bucles interiores para mejorar el rendimiento de la caché de memoria. Además, añadíamos punteros e instrucciones AVX. Para la nueva rutina, lo que invertimos son los índices de la segunda matrix y, en consecuencia, renunciamos al truco de intercambiar los bucles internos. Perdemos algo de velocidad pero, en cualquier caso, el resultado es mejor que trasponer explícitamente y luego multiplicar.

La rutina que finalmente está incluida en Austra es la siguiente:

public unsafe Matrix MultiplyTranspose(Matrix m)
{
    Contract.Requires(IsInitialized);
    Contract.Requires(m.IsInitialized);
    if (Cols != m.Cols)
        throw new MatrixSizeException();
    Contract.Ensures(Contract.Result<Matrix>().Rows == Rows);
    Contract.Ensures(Contract.Result<Matrix>().Cols == m.Rows);

    int r = Rows;
    int n = Cols;
    int c = m.Rows;
    double[,] result = new double[r, c];
    fixed (double* pA = values, pB = m.values, pC = result)
    {
        int lastBlockIndex = n & CommonMatrix.AVX_MASK;
        double* pAi = pA;
        double* pCi = pC;
        for (int i = 0; i < r; i++)
        {
            double* pBj = pB;
            for (int j = 0; j < c; j++)
            {
                int k = 0;
                double acc = 0;
                if (Avx.IsSupported)
                {
                    Vector256<double> sum = Vector256<double>.Zero;
                    for (; k < lastBlockIndex; k += 4)
                        sum = Avx.Add(
                            left: Avx.Multiply(
                                left: Avx.LoadVector256(pAi + k),
                                right: Avx.LoadVector256(pBj + k)),
                            right: sum);
                    sum = Avx.HorizontalAdd(sum, sum);
                    acc = sum.ToScalar() + sum.GetElement(2);
                }
                for (; k < n; k++)
                    acc += pAi[k] * pBj[k];
                pCi[j] = acc;
                pBj += n;
            }
            pAi += n;
            pCi += c;
        }
    }
    return result;
}

Una parte importante del truco es hacer que el lenguaje que implementa Austra reconozca la multiplicación por la traspuesta y sepa usar la operación más eficiente. Pero eso es fácil de conseguir, y lo explicaré en otro momento.

Categorías
C#

Blazor

Empecé mis experimentos con Blazor en los lejanos tiempos de .NET 5. Hace un par de años, quiero decir.

Con un poco de contexto me explicaré mejor: prefiero una buena «aplicación de escritorio» antes que cualquier página web. Puede ser porque el tipo de proyectos en los que he invertido más tiempo han sido siempre aplicaciones con alta densidad de información, como las llaman ahora. Pero también porque he podido comparar la productividad de equipos trabajando para la web y para aplicaciones de escritorio de toda la vida. La pérdida de productividad, según mi experiencia personal, se agrava cuando es Angular la herramienta de front-end. Conozco TypeScript en profundidad, y he hecho proyectos con Angular y con Svelte. Svelte es muchísimo más productivo, e infinitamente menos pesado luego en producción. Pero la industria tiene cierta fijación con el puñetero Angular.

Las primeras pruebas con Blazor las hice con la idea de usarlo para crear prototipos rápidos. Funcionó de maravillas. Pero no estaba muy puesto en Bootstrap por entonces, y el diseño visual de los ejemplos de Microsoft era, y sigue siendo, abominable. Me preocupaba, además, la falta de controles de terceros. El cliente, por ejemplo, estaba empeñado en usar un gráfico de radar en la página principal del proyecto… y los componentes que manejábamos no traían el dichoso gráfico. Al final, el front-end terminó haciéndose en React, pero el prototipo de Blazor sigue existiendo y usándose internamente, porque en varios aspectos, es más rápido y potente.

Una de las lecciones más importantes de mi carrera como desarrollador, es que si te toca crear una librería, un servidor o algo que normalmente no tenga una interfaz de usuario… es mejor que te crees una interfaz propia, aunque no te la paguen ni te la agradezcan. Es tu seguro de vida para que no te culpen si el front-end o la aplicación hecha por otros es lenta. La vida es dura.

Ahora mismo, acabo de terminar un proyecto grande con Blazor y ASP.NET. Bueno, realmente el prototipo, porque los «sabios» de la «industria» siguen obcecados con Angular y Spring Boot, y ahora habrá que rehacerlo todo. Blazor es ya una herramienta de desarrollo madura. Yo he aprendido un poco más de CSS y HTML (desastres ambos, en mi opinión) y la parte de la fealdad ya está más o menos superada. Los componentes de terceros han evolucionado también. Y hay mejores libros y blogs sobre cómo funciona Blazor, que no siempre es lo que aparenta. La velocidad de desarrollo sigue siendo incomparable. He terminado la funcionalidad necesaria del proyecto en un mes. Calculamos que rehacerlo todo en Angular nos costará tres veces más tiempo. La estimación de tiempo no es mía: mi papel ha sido rebajarla, porque al fin y al cabo, ya sabemos cómo hacer todo.

Me siento un poco como Sísifo. Pero uno termina por acostumbrarse a estas cosas.

Categorías
Insights Música

Sonata en Mi menor

Llevo un mes practicando esta pieza, en este arreglo:

No es difícil. Ólafsson le añade al arreglo original para piano, de August Stradal, una sección final en la que dobla el ritmo de la mano derecha. Los puristas del Barroco suelen ser críticos con este tipo de arreglos, pero no es mi problema. La pieza es el segundo movimiento (suele ser un Andante) de la Sonata en Mi menor para órgano de Bach. Esta es una ejecución de la partitura original:

Naturalmente, la primera diferencia tiene que ver con cómo adaptas una pieza para dos manuales y una pedalera para un piano. Hay otra pequeña diferencia: los trinos escritos para el órgano no se tocan siempre en el piano. Cuestión de gusto del intérprete. Al fin y al cabo, la ornamentación es sólo un adorno (excepto en el Aria de las Variaciones Goldberg, en la que es difícil separar la pieza de la ornamentación «a la francesa»).

Categorías
FinTech

Austra

Otro pequeño interludio: si os interesan estas cosas, echad un vistazo a la ayuda online de Austra. Es un lenguaje de expresiones orientado al manejo de series financieras y modelos econométricos. Está todavía en desarrollo, pero ya va tomando forma. Entre otras cosas interesantes, permite usar funciones lambda, y la librería, Austra.Library, utiliza AVX2 para optimizar las operaciones sobre matrices, vectores y series temporales. Hasta 200/300 dimensiones, es generalmente más eficiente que las alternativas, incluso las que usan Intel MKL. A partir de ese punto, naturalmente, Intel MKL es más eficiente. En cualquier caso, tengo la posibilidad de mejorar la implementación para ese volumen de información. Y como última alternativa, puedo incorporar Intel MKL como una opción más; opcional, por supuesto, porque esta librería tiene licencias de pago.

Mi idea es, en el momento en que esté suficientemente maduro, convertir parte del código en open source. Aparte de añadir más algoritmos a la librería básica, quiero dar alternativas al analizador del lenguaje. Ahora mismo, el lenguaje genera árboles de expresiones, que a su vez generan código nativo sobre la marcha. Para una línea de comandos, sin embargo, puede que esto sea excesivo. No voy a destruir el compilador actual, pero quiero subir, antes de abrir el código, un intérprete más modesto que, al mismo tiempo, seguramente será más eficiente para el propósito de la herramienta.

Categorías
Quantum

CNOT

Como no he encontrado una imagen interesante para ilustrar una compuerta CNOT, he puesto el yelmo de Knut, que suena parecido. Sí, a veces las neuronas me patinan.

Compuertas condicionales

El siguiente circuito, extremadamente simple, muestra una compuerta CNOT aplicada al primer y segundo qubit del circuito:

CNOT significa «conditional NOT», una negación condicional, y el parentesco con la negación a secas se evidencia en la simbología asociada: la parte inferior del símbolo es la cruz dentro del círculo que se suele usar para la negación. Se trata de una compuerta que trabaja siempre con dos qubits. Todas las transformaciones que hemos visto hasta ahora sobre dos qubits se creaban componiendo transformaciones sobre un qubit. Con esta compuerta, ya saltamos a transformaciones más complejas.

El papel de una compuerta CNOT consiste en invertir el estado de un qubit de acuerdo con el estado de otro qubit de control. Si el qubit de control vale $0$, el otro bit no se modifica. Si el bit de control es $1$, el otro bit se invierte. En el circuito que acabamos de mostrar, el qubit de control es el menos significativo, y esta es la acción de la compuerta sobre la base del estado:
$$\eqalign{
\operatorname{CNOT}\vert00\rangle&\rightarrow\vert00\rangle\cr
\operatorname{CNOT}\vert01\rangle&\rightarrow\vert11\rangle\cr
\operatorname{CNOT}\vert10\rangle&\rightarrow\vert10\rangle\cr
\operatorname{CNOT}\vert11\rangle&\rightarrow\vert01\rangle
}$$¿Recuerda nuestro truco ninja en la entrada anterior, para calcular rápidamente la acción de una matriz sobre vectores de la base? Cuando una matriz actúa sobre un vector de la base, el resultado se obtiene seleccionando una columna. Eso significa que la primera columna de la matriz tiene que ser el resultado de aplicarla al primer vector de la base, y la última, es el resultado sobre el último vector de la base. Pero yo acabo de enumerar lo que ocurre cuando se aplica CNOT a cada uno de los vectores de la base. Podemos entonces dar ya la representación de CNOT como matriz:

$$\operatorname{CNOT_{LS}} = \pmatrix{1&0&0&0\cr 0&0&0&1\cr 0&0&1&0\cr 0&1&0&0
}$$Para evitar confusiones, he usado el sufijo $ls$ para indicar que esta es la matriz cuando el qubit de control es el menos significativo. Si hacemos que el control lo ejerza el bit más significativo, el de la izquierda, la matriz correspondiente sería:
$$\operatorname{CNOT_{MS}} = \pmatrix{1&0&0&0\cr 0&1&0&0\cr 0&0&0&1\cr 0&0&1&0
}$$¡Compruébelo!

En general, si tenemos una compuerta de un qubit y queremos aplicarla condicionalmente sobre el qubit menos significativo, usando el más significativo como control, la matriz que necesitamos sigue este patrón:

$$\operatorname{CondA} = \pmatrix{I & 0 \cr 0 & A
}$$No hay nada mágico en la fórmula anterior. Si se aplica $\operatorname{CondA}$ a los vectores $\vert00\rangle$ o $\vert01\rangle$, obtenemos el vector original, sin cambios, porque el qubit más significativo de estos dos vectores es $0$. Las dos columnas en las que la matriz original $A$ pinta algo son la tercera y la cuarta, que son los estados de la base con el qubit más significativo a $1$.

The twilight zone

Confieso que me preocupa un poco que en la mayoría de los ejemplos estemos usando matrices con ceros y unos en sus celdas. Puede dar la impresión de que estamos usando valores lógicos, y no es así ni remotamente. Donde estamos viendo un cero, podríamos tener un número complejo arbitrario.

Para compensar esta tendencia, vamos a movernos ahora a la zona «crepuscular». Todos comprendemos sin problemas lo que ocurre cuando el qubit de control es cero o uno: nuestra mente está habituada a trabajar en blanco y negro, ¿no? Pero, ¿qué ocurre si el qubit de control es un gato de Schrödinger? Vamos a diseñar un circuito en el que aplicaremos una transformación de Hadamard precisamente a ese qubit:

En notación algebraica, este circuito se puede escribir así:

$$\operatorname{CNOT_{LS}} \times (I \otimes H)$$¡Mucha atención al orden, que es muy importante! En primer lugar, he puesto la matriz de la condicional a la izquierda de la expresión, aunque es la segunda que se aplica en el circuito. ¿Por qué? Pues porque cuando yo escribo $ABx$ estoy pidiendo que primero se aplique la matriz $B$ al vector $x$, y luego al resultado se le aplique la matriz $A$. Es decir, en $ABx$, la matriz de más a la izquierda es la segunda que se aplica, no la primera.

Por otra parte, escribo $I \otimes H$ porque la matriz de la izquierda es la que corresponde al qubit más significativo, como vimos en la entrada anterior. Hadamard se le aplica sólo al qubit menos significativo, y para completar el circuito, aplicamos la matriz identidad al qubit más significativo.

Ahora calculemos la matriz por partes. Primero, la combinación tensorial de la identidad con Hadamard:

$$I\otimes H = \pmatrix{H&0\cr 0&H} = \frac{1}{\sqrt2}\pmatrix{1&1&0&0\cr1&-1&0&0\cr0&0&1&1\cr0&0&1&-1
}$$La matriz $\operatorname{CNOT}$ que utiliza el qubit menos significativo como control ya la hemos presentado antes. Sólo nos queda multiplicar dos matrices:

$$\frac{1}{\sqrt2}\pmatrix{1&0&0&0\cr 0&0&0&1\cr 0&0&1&0\cr 0&1&0&0} \times \pmatrix{1&1&0&0\cr1&-1&0&0\cr0&0&1&1\cr0&0&1&-1}
$$¿Sabe lo que haría un ninja del álgebra ahora? Sólo calcularía la primera columna del resultado, porque es la que nos importa dado el estado cuántico inicial. Pero como soy buena persona, aquí le dejo la matriz completa del circuito (pero no se fíe de mí, y haga el cálculo por su cuenta):

$$\frac{1}{\sqrt2}\pmatrix{1&1&0&0\cr 0&0&1&-1\cr 0&0&1&1\cr 1&-1&0&0}
$$Como ya hemos dicho, esta matriz tiene que transformar un estado cuántico inicial que es el $\vert00\rangle$, o $\pmatrix{1&0&0&0}^T$ en notación matricial (la $T$ significa que hay que transponer para obtener un vector de columna). El resultado es la primera columna de la matriz de transformación:

$$\frac{1}{\sqrt2}\pmatrix{1\cr0\cr0\cr1}
$$Y este, amigos míos, a pesar de su apariencia inocente, es un estado entrelazado. En concreto, es uno de los estados de Bell, el estado $\vert\Phi^{+}\rangle$. Como dicen en inglés, we’ve got a crazy cookie.

Einstein-Podolski-Rosen

¿Qué ocurre cuando se realiza una medición sobre este estado? Si aplicamos la regla de Born, vemos que podemos obtener solamente uno de dos resultados, $00$ o $11$, de los cuatro resultados posibles. Si obtenemos un $1$ en el bit menos significativo, no tenemos que mirar el más significativo para saber que obligatoriamente es otro $1$.

Si en el diseñador de circuitos de Qiskit creamos este circuito y miramos el panel «Measurement probabilities» veremos este gráfico:

Efectivamente, nos advierte que hay dos posibles lecturas, las antes mencionadas, y que cada una se obtiene en un 50% de los casos.

Categorías
Quantum

Cuatro gatos

Vamos a probar fortuna ahora con un circuito mucho más interesante, que a la larga utilizaremos con cierta frecuencia. Este es el circuito:

Las compuertas $H$, naturalmente, corresponden a transformadas de Hadamard aplicadas a qubits individuales.

SuperHadamard

A modo de recordatorio, esta es la definición de la matriz de Hadamard:

$$H = \frac{1}{\sqrt2} \pmatrix{
1&1\cr 1&-1
}$$El coeficiente $\frac{1}{\sqrt2}$ se incluye para que el determinante de la matriz sea la unidad.

Cuando la transformada de Hadamard se aplica a un qubit que está en el estado inicial $\vert0\rangle$, lo lleva al estado $\vert+\rangle$ de la base de Hadamard, que se expresa en la base computacional como $\frac{1}{\sqrt2}(\vert0\rangle + \vert1\rangle)$. Este es un estado superpuesto, un gato de Schrödinger, que cuando se mide, va a alternar entre ceros y unos con la misma probabilidad. Si nos ponemos solemnes, podríamos decir que acabamos de implementar un generador aleatorio de valores binarios.

¿Qué pasa si tengo dos qubits y le aplico Hadamard a cada uno de ellos? Ya sabemos que para tener una respuesta exacta tenemos que multiplicar tensorialmente dos matrices de Hadamard. Este es el cálculo:

$$\eqalign{
H \otimes H &= \frac{1}{2} \pmatrix{1&1\cr 1&-1} \otimes \pmatrix{1&1\cr 1&-1}\cr
&= \frac{1}{2}\pmatrix{1&1&1&1\cr 1&-1&1&-1\cr 1&1&-1&-1\cr 1&-1&-1&1 }
}$$Ahora queremos saber qué pasa si aplicamos esta matriz al estado inicial del circuito, $\vert00\rangle$:

$$(H\otimes H)\vert00\rangle = \frac{1}{2}\pmatrix{1&1&1&1\cr 1&-1&1&-1\cr 1&1&-1&-1\cr 1&-1&-1&1 } \pmatrix{1\cr0\cr0\cr0} = \frac{1}{2} \pmatrix{1\cr1\cr1\cr1}
$$Vamos a entender mejor el resultado si lo representamos en notación de Dirac:
$$\frac{1}{2}\vert0\rangle + \frac{1}{2}\vert1\rangle + \frac{1}{2}\vert2\rangle + \frac{1}{2}\vert3\rangle
$$Esta es una superposición uniforme de los cuatro vectores de la base de dos qubits. Es decir: un gato de Schrödinger simultáneamente en cuatro estados. También podemos interpretarlo como una superposición uniforme de todos los números enteros del cero al tres.

Si aplicamos una medición a este estado, el resultado puede ser cualquiera de los números del cero al tres, con la misma probabilidad para cada uno de ellos (un cuarto). De vuelta a la solemnidad: hemos programado un generador aleatorio que escupe números del $0$ al $3$.

Los goces de la linealidad

Imaginemos que hemos diseñado un circuito multiqubits, que recibe un número entero de entrada y que hace algún cálculo mágico y genera un valor entero muy interesante por el otro extremo. No voy a complicar la fiesta ahora recordando que ese circuito debe implementar una transformación unitaria. Si lo tuviésemos en cuenta, tendríamos que introducir qubits adicionales para mantener la reversibilidad del circuito. Pero vamos a olvidarnos ahora de ese problema, y aceptemos que tenemos un algoritmo mágico que transforma un número en otro número más interesante.

Supongamos ahora que, en vez de pasarle un número al algoritmo, le pasamos de golpe una superposición uniforme de todos los números representables en el número de qubits de los que disponemos. ¿Qué calculará el algoritmo? Como el «algoritmo» es una matriz, es decir, una transformación lineal, lo que obtendremos es una superposición uniforme de todos los resultados. Es como si hubiésemos aplicado el algoritmo en paralelo a todos los números enteros que podemos representar en nuestro ordenador:

$$A\times \sum_i { \alpha_i \hat{e}_i } = \sum_i { \alpha_i (A\times \hat{e}_i) }
$$Esta propiedad se conoce como paralelismo cuántico, y no es tan útil como parece a primera vista. No lo es porque, cuando midamos, colapsaremos el estado en un único vector propio, y perderemos toda esa información. De todos modos, pronto veremos cómo sacar partido de esta situación.

Algebra lineal, nivel Dios

Termino esta entrada con una nota técnica sobre álgebra lineal, que nos va a ser muy útil para obtener el grado de ninja. A todos nos han contado, en un momento u otro de nuestras vidas, que el mundo está lleno de vectores que pastan por los prados y de matrices cuya obsesión en la vida es transformar cada vector que se le ponga a tiro:

$$Ax$$Vamos a plantearnos esta relación desde otro punto de vista. ¿Y si realmente es el vector quien está actuando sobre la matriz para generar otro vector? Podemos hacerlo, siempre que consideremos que una matriz es una colección de columnas, en vez de una colección de filas, que es la forma más habitual de verlas. Observe cómo un vector parasita a una matriz y genera un vector combinando linealmente las columnas de la misma:

$$\pmatrix{a_1&b_1&c_1\cr a_2&b_2&c_2\cr a_3&b_3&c_3} \pmatrix{x_1\cr x_2\cr x_3} = x_1\pmatrix{a_1\cr a_2\cr a_3} + x_2\pmatrix{b_1\cr b_2\cr b_3} + x_3\pmatrix{c_1\cr c_2\cr c_3}
$$Esta intuición nos sirve, si estamos implementando una librería de matrices con aceleración por hardware, para representar las matrices como vectores de columnas. Lo he probado en C++, usando funciones intrínsecas y, efectivamente, es más eficiente implementar la multiplicación de matrices por vectores usando SIMD como una combinación lineal de las columnas de la matriz. De hecho, el convenio de FORTRAN siempre ha sido almacenar las matrices por columnas, no por filas.

Pero no es esto lo que quiero mostrarle exactamente. Imaginemos que el vector $x$ pertenece a la base. En ese caso, uno de sus componentes será un $1$, y el resto, $0$. Veamos cómo transformaría nuestra matriz de Hadamard de $4\times4$ el estado $\vert10\rangle$:

$$(H\otimes H)\vert10\rangle = \frac{1}{2}\pmatrix{1&1&1&1\cr 1&-1&1&-1\cr 1&1&-1&-1\cr 1&-1&-1&1 } \pmatrix{0\cr0\cr1\cr0} = \frac{1}{2} \pmatrix{1\cr1\cr-1\cr-1}
$$Si observamos bien, vemos que lo que ha hecho el vector es seleccionar directamente la tercera columna de la matriz. ¿Por qué? Pues porque $\vert10\rangle$ es el tercer vector de la base: dos ceros, un uno y otro cero. Una combinación lineal de las columnas, en este caso, va directamente a por la tercera columna.

Esto tiene una segunda consecuencia: ya hemos visto que en un ordenador cuántico, el vector de estado siempre se inicializa con ceros: $\vert000\cdots0\rangle$. Este es el primer vector de la base computacional: un $1$ en el primer componente, seguido de un montón de ceros. ¿Qué le va a hacer este astuto vector a una inocente matriz? ¡Pues seleccionar la primera columna!

Esto es un truco muy útil. Si tenemos una matriz de $2^N\times2^N$ que representa a todo un circuito, en realidad sólo necesitamos las $2^N$ celdas de la primera columna, porque sabemos a ciencia cierta cómo funciona la inicialización del ordenador cuántico.

Categorías
Quantum

Compuertas y circuitos

Tras presentar el estado cuántico de un qubit, vimos las transformaciones unitarias que actuaban sobre dicho estado. Ahora, que ya hemos presentado el estado de un grupo de qubits, tenemos que ver las transformaciones unitarias que nos permitirán manejar su estado.

Compuertas

Cuando se trata de operaciones sobre qubits individuales, no es mayor problema utilizar directamente las matrices en notación de componentes. El vector de estado de un qubit se representa con dos componentes complejos, por lo que las matriz son «simples» matrices complejas de $2\times2$. En cambio, es más pesado tener que listar los componentes de matrices con más dimensiones.

La alternativa, que todo el mundo sigue, es representar las transformaciones mediante compuertas cuánticas y combinar estas compuertas en circuitos. Por supuesto, detrás de todo circuito hay, simple y llanamente, una matriz probablemente enorme que es la que realiza la transformación necesaria antes de la medición. A mí, personalmente, me resulta muchísimo más fácil entender el funcionamiento de un algoritmo calculando las matrices asociadas pero, eso espero, con el tiempo uno llega a familiarizarse con la notación de circuitos y desarrollar la intuición necesaria para ahorrarse unos cuantos pasos. En cualquier caso, intentaré dar las herramientas para pasar de compuertas a matrices y, en el caso de circuitos correspondientes a algoritmos sencillos, del circuito completo a la matriz resultante.

Para el diseño de circuitos y algoritmos, mi recomendación es utilizar Qiskit, de IBM. Qiskit te permite crear una cuenta gratuita, y te da acceso a dos herramientas muy útiles para crear y evaluar circuitos: mediante software gráfico y a través de Python. No soy un entusiasta de Python, pero para este tipo de tareas no duele mucho usarlo. La siguiente imagen muestra parte de la barra de herramientas del editor gráfico, y un circuito muy sencillo con dos qubits, a los cuales se le está aplicando una inversión (la compuerta que parece una cruz) y una medición (la línea inferior representa la salida de bits clásicos del algoritmo):

El objetivo de esta entrada no es ponernos ya a diseñar circuitos: cuando llegue el momento, es preferible mostrar el código en Python. Ahora nos interesa la parte matemática, sobre cómo definir transformaciones que afecten a más de un qubit. Utilizo Qiskit para no tener que dibujar los circuitos a mano.

Hay alternativas a Qiskit. Las principales que conozco son Q#, una iniciativa de Microsoft, e incluso librerías de Matlab. Q# es interesante, pero en estos momentos es una pequeña tragedia hacer que funcione a la primera en Visual Studio. Y Matlab es de pago.

Cómo combinar matrices

Volvamos al circuito que he mostrado antes:
Este circuito muestra dos qubits, $q_0$ y $q_1$. A cada uno de ellos se le aplica una negación: la matriz $X$ que ya hemos visto, que convierte ceros en unos y viceversa, y que Qiskit representa como un círculo con una cruz dentro. Luego se realizan las mediciones. El resultado es muy tonto: obtenemos un $11$ clásico:

  1. Asumimos que cada qubit se inicializa en el estado $\vert0\rangle$. Es decir, el sistema de dos qubits se inicializa en el estado conjunto $\vert00\rangle$.
  2. Cada inversión convierte su qubit al estado $\vert1\rangle$. El sistema queda en un estado puro $\vert11\rangle$.
  3. Por supuesto, al medir obtendremos ese estado, sin ningún tipo de incertidumbre.

Sabemos que la matriz o compuerta $X$ tiene la siguiente representación en notación de componentes:
$$\pmatrix{0&1\cr1&0}
$$La pregunta nuestra es: ¿cómo representamos la matriz que aplica una negación por separado a cada uno de los qubits? En este caso, la intuición podría ayudarnos a determinar el contenido de la matriz, pero vamos a aprovechar que es un ejercicio sencillo para poner a punto la herramienta matemática que necesitaremos para casos más complicados. Esa herramienta es la multiplicación tensorial de matrices (previsible, ¿no?).

Supongamos que tenemos dos matrices $A$ y $B$, y que queremos calcular el producto tensorial de las dos:
$$\pmatrix{a_{11}&a_{12}\cr a_{21}&a_{22}}\otimes\pmatrix{b_{11}&b_{12}\cr b_{21}&b_{22}}
$$La definición de producto tensorial de matrices es un poco engorrosa, pero no es complicada:
$$\pmatrix{
a_{11}\times\pmatrix{b_{11}&b_{12}\cr b_{21}&b_{22}}&
a_{12}\times\pmatrix{b_{11}&b_{12}\cr b_{21}&b_{22}}\cr
a_{21}\times\pmatrix{b_{11}&b_{12}\cr b_{21}&b_{22}}&
a_{22}\times\pmatrix{b_{11}&b_{12}\cr b_{21}&b_{22}}
}$$Simplificando, nos queda esto:
$$\pmatrix{
a_{11}b_{11}&a_{11}b_{12}&a_{12}b_{11}&a_{12}b_{12}\cr
a_{11}b_{21}&a_{11}b_{22}&a_{12}b_{21}&a_{12}b_{22}\cr
a_{21}b_{11}&a_{21}b_{12}&a_{22}b_{11}&a_{22}b_{12}\cr
a_{21}b_{21}&a_{21}b_{22}&a_{22}b_{21}&a_{22}b_{22}
}$$Es muy importante dominar esta técnica. Asegúrese de que comprende de dónde sale cada celda del resultado antes de seguir adelante.

En nuestro ejemplo, queríamos combinar dos matrices $X$:
$$\pmatrix{0&1\cr1&0}\otimes\pmatrix{0&1\cr1&0}
$$En el paso intermedio, obtenemos esto:
$$\pmatrix{
0\times\pmatrix{0&1\cr1&0}&
1\times\pmatrix{0&1\cr1&0}\cr
1\times\pmatrix{0&1\cr1&0}&
0\times\pmatrix{0&1\cr1&0}
}$$Simplificando:
$$\pmatrix{
0&0&0&1\cr
0&0&1&0\cr
0&1&0&0\cr
1&0&0&0
}$$Intuitivamente, el resultado tiene buen aspecto: la matriz $X$ de dos dimensiones parece una matriz con la diagonal invertida, y esta matriz resultado tiene la misma forma. ¿Cómo comprobamos que es correcta? Muy sencillo. La base para dos qubits tiene cuatro vectores, que son $\vert00\rangle,\vert01\rangle,\vert10\rangle,\vert11\rangle$. Estos cuatro vectores, sin embargo, tienen una representación en componentes que puede resultar confusa si no la domina todavía. Vamos a hacer los cálculos y las representaciones explícitos. Para el primer vector de la base:
$$(X\otimes X)\vert00\rangle = \pmatrix{
0&0&0&1\cr
0&0&1&0\cr
0&1&0&0\cr
1&0&0&0
} \times \pmatrix {1\cr0\cr0\cr0} = \pmatrix {0\cr0\cr0\cr1} = \vert11\rangle
$$Para el segundo vector:
$$(X\otimes X)\vert01\rangle = \pmatrix{
0&0&0&1\cr
0&0&1&0\cr
0&1&0&0\cr
1&0&0&0
} \times \pmatrix {0\cr1\cr0\cr0} = \pmatrix {0\cr0\cr1\cr0} = \vert10\rangle
$$Tercer vector:
$$(X\otimes X)\vert10\rangle = \pmatrix{
0&0&0&1\cr
0&0&1&0\cr
0&1&0&0\cr
1&0&0&0
} \times \pmatrix {0\cr0\cr1\cr0} = \pmatrix {0\cr1\cr0\cr0} = \vert01\rangle
$$Y el cuarto vector:
$$(X\otimes X)\vert11\rangle = \pmatrix{
0&1&0&0\cr
1&0&0&0\cr
0&1&0&0\cr
1&0&0&0
} \times \pmatrix {0\cr0\cr0\cr1} = \pmatrix {1\cr0\cr0\cr0} = \vert00\rangle
$$Perdone que haya sido tan pedante y haya escrito explícitamente todos los detalles. La programación, en general, es el reino de los detalles. En este caso, hemos podido comprobar que la matriz combinada invierte los ceros y unos en el estado cuántico combinado.

El orden de los factores

Sin embargo, me he dejado un detalle importante en el tintero: el orden de los qubits. En un estado como $\vert01\rangle$, ¿cuál es el qubit más significativo? ¿Es el $0$ o el $1$? Todo depende del convenio, pero una vez elegido uno, hay que atenerse estrictamente a él. Nuestro convenio será que en $\vert01\rangle$ el bit más significativo es el de la izquierda, es decir, el $0$, y el menos significativo es el de la derecha, el $1$.

Recuerde, además, que $\vert01\rangle$ es realmente $\vert0\rangle\otimes\vert1\rangle$, esto es, un producto tensorial. Esto es muy importante también para combinar correctamente matrices. Nuestro primer ejemplo de producto tensorial fue muy sencillo porque las dos matrices eran idénticas, y no tuvimos que preocuparnos por su orden relativo. Vamos a suponer que nuestro circuito tiene esta otra forma:

Esta vez queremos invertir solamente el qubit menos significativo. Aparentemente, además, sólo tenemos una matriz, pero eso no es del todo cierto. Lo que queremos calcular ahora es la matriz combinada $I\otimes X$, donde $I$ es la matriz identidad.

Observe el orden: primero va la matriz identidad $I$… ¡porque cuando representamos el sistema de dos qubits, hemos elegido que el más significativo sea el de la izquierda! Y la matriz $X$ es entonces el segundo operando. Queremos entonces calcular esto:

$$\pmatrix{1&0\cr0&1}\otimes\pmatrix{0&1\cr1&0}
$$Expandiendo, según la definición de producto tensorial:
$$\pmatrix{
1\times\pmatrix{0&1\cr1&0}&
0\times\pmatrix{0&1\cr1&0}\cr
0\times\pmatrix{0&1\cr1&0}&
1\times\pmatrix{0&1\cr1&0}
}$$Y si simplificamos:
$$\pmatrix{
0&1&0&0\cr
1&0&0&0\cr
0&0&0&1\cr
0&0&1&0
}$$Esto, así de repente, es chino mandarín, y la única manera sensata de comprobar que no nos hemos equivocado es comprobar directamente las transformaciones.
$$(I\otimes X)\vert00\rangle = \pmatrix{
0&1&0&0\cr
1&0&0&0\cr
0&0&0&1\cr
0&0&1&0
} \times \pmatrix {1\cr0\cr0\cr0} = \pmatrix {0\cr1\cr0\cr0} = \vert01\rangle
$$
$$(I\otimes X)\vert01\rangle = \pmatrix{
0&1&0&0\cr
1&0&0&0\cr
0&0&0&1\cr
0&0&1&0
} \times \pmatrix {0\cr1\cr0\cr0} = \pmatrix {1\cr0\cr0\cr0} = \vert00\rangle
$$
$$(I\otimes X)\vert10\rangle = \pmatrix{
0&1&0&0\cr
1&0&0&0\cr
0&0&0&1\cr
0&0&1&0
} \times \pmatrix {0\cr0\cr1\cr0} = \pmatrix {0\cr0\cr0\cr1} = \vert11\rangle
$$
$$(I\otimes X)\vert11\rangle = \pmatrix{
0&1&0&0\cr
1&0&0&0\cr
0&0&0&1\cr
0&0&1&0
} \times \pmatrix {0\cr0\cr0\cr1} = \pmatrix {0\cr0\cr1\cr0} = \vert10\rangle
$$Nuestra matriz, por lo tanto, transforma $00\rightarrow01$, $01\rightarrow00$, $10\rightarrow11$ y $11\rightarrow11$. Esto es, lo que hace es cambiar el estado del bit menos significativo… que era lo que estábamos buscando. Como ejercicio, puede calcular cuál sería la representación en componentes de la matriz $X\otimes I$, que debe invertir el estado del qubit más significativo. Y si quiere adelantarse a la siguiente entrada, pruebe fortuna con $H\otimes H$, donde $H$ es la transformación de Hadamard que ya hemos visto.

Advertencia

Todas las matrices y estados que hemos visto tienen componentes que son o $0$ o $1$. Esto puede llevar a confusión: ese cero es realmente un cero «complejo», y lo mismo se aplica al uno. No pierda de vista, aunque en estos primeros ejemplos no importe mucho, que en vez de ceros y unos, nuestras matrices y vectores pueden contener, y contendrán, números complejos.