Files
go-mxl-pattern-generator/v210-mxl-architecture.md
Dmitry Sergeev f48195a8bf WGPU is coming
2026-09-07 17:18:03 +03:00

19 KiB
Raw Permalink Blame History

Архитектура v210 Генератора для Media eXchange Layer (go-mxl)

Профессиональная Zero-GC и Zero-Copy архитектура гибридного CPU/GPU v210 тестового генератора, оптимизированная под работу с официальным C SDK Media eXchange Layer (libmxl) через официальные Go-биндинги go-mxl от Qvest Digital.

🏢 Финальная структура проекта

v210-generator/
├── cmd/
│   └── v210-gen/
│       └── main.go         # Cobra CLI & главный цикл записи в MXL Flow
├── internal/
│   └── generator/
│       ├── generator.go    # Интерфейс генератора
│       ├── cpu.go          # CPU битовый упаковщик (Пишет сразу в []byte)
│       └── opencl.go       # OpenCL Cgo упаковщик (Маппит []byte из MXL в GPU)
└── kernels/
    └── v210_pack.cl    # Шейдер упаковки v210

1. Архитектура Генератора: internal/generator/generator.go

Так как go-mxl предоставляет память в виде стандартных байтовых срезов ([]byte), мы адаптируем интерфейс. Генераторы больше не создают буферы, они принимают сырой []byte, выделенный с помощью SDK.

package generator

type FrameGenerator interface {
	// GenerateFrame упаковывает данные напрямую в предоставленный байтовый срез.
	// Этот срез будет являться физической памятью MXL Shared Memory.
	GenerateFrame(dest []byte, frameIndex int) error
	Close() error
}

2. GPU Движок под MXL: internal/generator/opencl.go

Поскольку go-mxl дает нам прямой доступ к разделяемой памяти, мы берем этот срез []byte, берем его адрес в RAM через unsafe.Pointer и отдаем драйверу OpenCL через флаг CL_MEM_USE_HOST_PTR. Видеокарта запишет v210 паттерн напрямую в MXL, минуя кэш Go.

package generator

/*
#cgo linux LDFLAGS: -lOpenCL
#cgo windows LDFLAGS: -lOpenCL

#include <CL/cl.h>
#include <stdlib.h>

static cl_program build_cl_program(cl_context ctx, cl_device_id dev, const char* src, cl_int* err) {
    cl_program prog = clCreateProgramWithSource(ctx, 1, &src, NULL, err);
    if (*err != CL_SUCCESS) return NULL;
    *err = clBuildProgram(prog, 1, &dev, NULL, NULL, NULL);
    return prog;
}
*/
import "C"
import (
	"fmt"
	"os"
	"unsafe"
)

type OpenCLGenerator struct {
	ctx    C.cl_context
	queue  C.cl_command_queue
	kernel C.cl_kernel
	prog   C.cl_program
	dev    C.cl_device_id
	width  int
	height int
}

func NewOpenCLGenerator(width, height int, kernelPath string) (*OpenCLGenerator, error) {
	var err C.cl_int
	var platform C.cl_platform_id

	if err = C.clGetPlatformIDs(1, &platform, nil); err != C.CL_SUCCESS {
		return nil, fmt.Errorf("opencl: platform not found: %d", err)
	}

	g := &OpenCLGenerator{width: width, height: height}
	if err = C.clGetDeviceIDs(platform, C.CL_DEVICE_TYPE_GPU, 1, &g.dev, nil); err != C.CL_SUCCESS {
		return nil, fmt.Errorf("opencl: no suitable gpu device found")
	}

	g.ctx = C.clCreateContext(nil, 1, &g.dev, nil, nil, &err)
	if err != C.CL_SUCCESS {
		return nil, fmt.Errorf("opencl: ctx creation error")
	}

	g.queue = C.clCreateCommandQueueWithProperties(g.ctx, g.dev, nil, &err)
	if err != C.CL_SUCCESS {
		g.queue = C.clCreateCommandQueue(g.ctx, g.dev, 0, &err)
	}

	src, errGo := os.ReadFile(kernelPath)
	if errGo != nil {
		g.Close()
		return nil, fmt.Errorf("opencl: read kernel error: %w", errGo)
	}

	cSrc := C.CString(string(src))
	defer C.free(unsafe.Pointer(cSrc))

	g.prog = C.build_cl_program(g.ctx, g.dev, cSrc, &err)
	if err != C.CL_SUCCESS {
		g.Close()
		return nil, fmt.Errorf("opencl: compilation failed")
	}

	cKName := C.CString("generate_v210_pattern")
	defer C.free(unsafe.Pointer(cKName))
	g.kernel = C.clCreateKernel(g.prog, cKName, &err)
	if err != C.CL_SUCCESS {
		g.Close()
		return nil, fmt.Errorf("opencl: kernel init failed")
	}

	return g, nil
}

func (g *OpenCLGenerator) GenerateFrame(dest []byte, frameIndex int) error {
	var err C.cl_int
	byteSize := C.size_t(len(dest))

	// ВАЖНО: Мы маппим память MXL SDK ([]byte) напрямую в OpenCL буфер.
	// GPU запишет данные прямо в общую память MXL без лишнего копирования (Zero-Copy).
	clMem := C.clCreateBuffer(
		g.ctx,
		C.CL_MEM_WRITE_ONLY|C.CL_MEM_USE_HOST_PTR,
		byteSize,
		unsafe.Pointer(&dest), // Указатель на первый элемент MXL-слайса
		&err,
	)
	if err != C.CL_SUCCESS {
		return fmt.Errorf("opencl: mxl host pointer mapping failed: %d", err)
	}
	defer C.clReleaseMemObject(clMem)

	C.clSetKernelArg(g.kernel, 0, C.sizeof_cl_mem, unsafe.Pointer(&clMem))
	w, h, f := C.int(g.width), C.int(g.height), C.int(frameIndex)
	C.clSetKernelArg(g.kernel, 1, C.sizeof_int, unsafe.Pointer(&w))
	C.clSetKernelArg(g.kernel, 2, C.sizeof_int, unsafe.Pointer(&h))
	C.clSetKernelArg(g.kernel, 3, C.sizeof_int, unsafe.Pointer(&f))

	// Каждый поток GPU пакует блок из 4 пикселей (16 байт)
	globalWorkSize := C.size_t((g.width * g.height) / 4)
	err = C.clEnqueueNDRangeKernel(g.queue, g.kernel, 1, nil, &globalWorkSize, nil, 0, nil, nil)
	if err != C.CL_SUCCESS {
		return fmt.Errorf("opencl: dispatch failed: %d", err)
	}

	// Ждем окончания записи видеокарты в память MXL
	C.clFinish(g.queue)
	return nil
}

func (g *OpenCLGenerator) Close() error {
	if g.kernel != nil { C.clReleaseKernel(g.kernel) }
	if g.prog != nil { C.clReleaseProgram(g.prog) }
	if g.queue != nil { C.clReleaseCommandQueue(g.queue) }
	if g.ctx != nil { C.clReleaseContext(g.ctx) }
	return nil
}

3. CPU Паковщик: internal/generator/cpu.go

CPU-генератор делает то же самое, но использует безопасную кастомную битовую маску для работы с []byte напрямую вместо []uint32, чтобы избежать преобразования типов данных.

package generator

import "encoding/binary"

type CPUGenerator struct {
	width  int
	height int
}

func NewCPUGenerator(width, height int) *CPUGenerator {
	return &CPUGenerator{width: width, height: height}
}

func (g *CPUGenerator) GenerateFrame(dest []byte, frameIndex int) error {
	totalBlocks := (g.width * g.height) / 4
	
	for blockID := 0; blockID < totalBlocks; blockID++ {
		baseX := (blockID * 4) % g.width
		
		y0 := uint32((baseX * 1023) / g.width)
		y1 := uint32(((baseX + 1) * 1023) / g.width)
		y2 := uint32(((baseX + 2) * 1023) / g.width)
		y3 := uint32(((baseX + 3) * 1023) / g.width)
		u0, v0, u2, v2 := uint32(512), uint32(512), uint32(512), uint32(512)

		w0 := (u0 & 0x3FF) | ((y0 & 0x3FF) << 10) | ((v0 & 0x3FF) << 20)
		w1 := (y1 & 0x3FF) | ((u2 & 0x3FF) << 10) | ((y2 & 0x3FF) << 20)
		w2 := (v2 & 0x3FF) | ((y3 & 0x3FF) << 10) | ((u0 & 0x3FF) << 20)
		w3 := uint32(0)

		// Записываем 4 слова (16 байт) прямо в байтовый слайс памяти MXL
		byteIdx := blockID * 16
		binary.LittleEndian.PutUint32(dest[byteIdx+0:], w0)
		binary.LittleEndian.PutUint32(dest[byteIdx+4:], w1)
		binary.LittleEndian.PutUint32(dest[byteIdx+8:], w2)
		binary.LittleEndian.PutUint32(dest[byteIdx+12:], w3)
	}
	return nil
}

func (g *CPUGenerator) Close() error { return nil }

4. Интеграция и Главный Цикл MXL: cmd/v210-gen/main.go

Здесь мы запускаем MXL инстанс и пишем фреймы. Обратите внимание, что мы не используем sync.Pool. Нам больше не нужно менеджить память в Go — MXL SDK сам выдает нам кусок разделяемой памяти для текущего индекса зерна (Grain). Наша задача — наполнить его и вызвать Commit().

package main

import (
	"fmt"
	"log"
	"time"
	"v210-generator/internal/generator"

	"github.com/qvest-digital/go-mxl/mxl"
)

func main() {
	useGPU := true
	width, height := 1920, 1080
	flowUUID := "your-video-flow-uuid-here"

	// 1. Инициализируем инстанс MXL (работает через /dev/shm/mxl)
	inst, err := mxl.NewInstance("/dev/shm/mxl", "")
	if err != nil {
		log.Fatalf("MXL Init failed: %v", err)
	}
	defer inst.Close()

	// 2. Создаем Writer для отправки потока данных в MXL Fabric
	writer, err := inst.NewWriter(flowUUID)
	if err != nil {
		log.Fatalf("Failed to create MXL Writer: %v", err)
	}
	defer writer.Close()

	// 3. Выбираем движок рендеринга
	var engine generator.FrameGenerator
	if useGPU {
		engine, err = generator.NewOpenCLGenerator(width, height, "../../kernels/v210_pack.cl")
		if err != nil {
			log.Printf("⚠️ GPU OpenCL failed: %v. Falling back to CPU.", err)
			engine = generator.NewCPUGenerator(width, height)
		}
	} else {
		engine = generator.NewCPUGenerator(width, height)
	}
	defer engine.Close()

	// Получаем параметры таймингов из конфига потока
	info, _ := writer.Info()
	rate := info.Config.Common.GrainRate
	idx := mxl.CurrentIndex(rate)

	fmt.Println("📺 Broadcasting v210 patterns directly into MXL Shared Memory...")

	for {
		// Алоцируем Grain (зерно данных) в разделяемой памяти MXL.
		// В этот момент SDK выделяет структуру под капот.
		grain, err := writer.OpenGrain(idx, 100*time.Millisecond)
		if err != nil {
			log.Printf("Failed to open MXL grain for index %d: %v", idx, err)
			time.Sleep(10 * time.Millisecond)
			continue
		}

		// grain.Payload — это []byte, ссылающийся напрямую на Linux Shared Memory
		// Мы передаем его в наш генератор
		err = engine.GenerateFrame(grain.Payload, int(idx))
		if err != nil {
			log.Printf("Render error at index %d: %v", idx, err)
			grain.Cancel() // Отменяем транзакцию для этого кадра, если произошла ошибка
			idx++
			continue
		}

		// Фиксируем (коммитим) кадр. С этого момента MXL рассылает его подписчикам (Readers)
		grain.Commit()
		
		idx++
	}
}

🏎️ Почему эта связка идеальна?

  • Полный Zero-Copy: Благодаря go-mxl от Qvest Digital, буферы памяти выделяются прямо в Linux Shared Memory (/dev/shm/mxl). Мы берем указатель на этот слайс и маппим его в OpenCL с помощью CL_MEM_USE_HOST_PTR. Видеокарта выполняет операции упаковки и складывает кадры прямо в оперативную память обмена.
  • 0% нагрузки на GC в Go: В главном цикле for отсутствуют динамические аллокации в куче (heap), рантайм Go работает без пауз на очистку памяти (STW), гарантируя идеальный frame pacing для Real-time Broadcast систем.

BARS

__kernel void generate_v210_pattern(__global uint* output, int width, int height, int frame_index) { int g_id = get_global_id(0); int total_blocks = (width * height) / 4; if (g_id >= total_blocks) return;

int pixel_x = (g_id * 4) % width;
int pixel_y = (g_id * 4) / width;

// В SMPTE RP 219 полосы занимают верхние 67% экрана. Ниже идут другие элементы.
// Для простоты примера сделаем полосы на весь экран, либо вы можете сделать split по pixel_y.

// Вычисляем ширину одной полосы (всего их 7)
int bar_width = width / 7;
int bar_index = pixel_x / bar_width;
if (bar_index > 6) bar_index = 6; // Защита от выхода за границы из-за округления

// Массивы констант для 75% SMPTE полос
ushort Y_table[7]   = {721, 647, 569, 495, 315, 241, 163};
ushort U_table[7]   = {512, 176, 599, 263, 761, 425, 848};
ushort V_table[7]   = {512, 557, 176, 221, 803, 848, 467};

// Так как поток берет 4 пикселя подряд, определим цвета для каждого из них.
// (В 95% случаев они попадут в одну полосу, но на стыках полос пиксели будут разными)
ushort y0 = Y_table[(pixel_x + 0) / bar_width > 6 ? 6 : (pixel_x + 0) / bar_width];
ushort y1 = Y_table[(pixel_x + 1) / bar_width > 6 ? 6 : (pixel_x + 1) / bar_width];
ushort y2 = Y_table[(pixel_x + 2) / bar_width > 6 ? 6 : (pixel_x + 2) / bar_width];
ushort y3 = Y_table[(pixel_x + 3) / bar_width > 6 ? 6 : (pixel_x + 3) / bar_width];

// v210 — это 4:2:2. Берем Хрому (U/V) от первого пикселя каждой пары (субдискретизация)
ushort u0 = U_table[(pixel_x + 0) / bar_width > 6 ? 6 : (pixel_x + 0) / bar_width];
ushort v0 = V_table[(pixel_x + 0) / bar_width > 6 ? 6 : (pixel_x + 0) / bar_width];

ushort u2 = U_table[(pixel_x + 2) / bar_width > 6 ? 6 : (pixel_x + 2) / bar_width];
ushort v2 = V_table[(pixel_x + 2) / bar_width > 6 ? 6 : (pixel_x + 2) / bar_width];

// Битовая упаковка v210 в четыре 32-битных слова
uint word0 = (u0 & 0x3FF) | ((y0 & 0x3FF) << 10) | ((v0 & 0x3FF) << 20);
uint word1 = (y1 & 0x3FF) | ((u2 & 0x3FF) << 10) | ((y2 & 0x3FF) << 20);
uint word2 = (v2 & 0x3FF) | ((y3 & 0x3FF) << 10) | ((u0 & 0x3FF) << 20);
uint word3 = 0; // 4-е слово в схеме v210 несет только выравнивание (высшие биты пусты)

int out_idx = g_id * 4;
output[out_idx + 0] = word0;
output[out_idx + 1] = word1;
output[out_idx + 2] = word2;
output[out_idx + 3] = word3;

}

BARS CPU

package generator

import "encoding/binary"

type CPUGenerator struct { width int height int }

func NewCPUGenerator(width, height int) *CPUGenerator { return &CPUGenerator{width: width, height: height} }

func (g *CPUGenerator) GenerateFrame(dest []byte, frameIndex int) error { totalBlocks := (g.width * g.height) / 4 barWidth := g.width / 7

// SMPTE Таблицы констант
Y_table := [7]uint32{721, 647, 569, 495, 315, 241, 163}
U_table := [7]uint32{512, 176, 599, 263, 761, 425, 848}
V_table := [7]uint32{512, 557, 176, 221, 803, 848, 467}

getIdx := func(x int) int {
	idx := x / barWidth
	if idx > 6 { return 6 }
	return idx
}

for blockID := 0; blockID < totalBlocks; blockID++ {
	baseX := (blockID * 4) % g.width

	// Запрашиваем индексы цветов для 4 пикселей в блоке
	idx0 := getIdx(baseX + 0)
	idx1 := getIdx(baseX + 1)
	idx2 := getIdx(baseX + 2)
	idx3 := getIdx(baseX + 3)

	y0, y1, y2, y3 := Y_table[idx0], Y_table[idx1], Y_table[idx2], Y_table[idx3]
	u0, v0 := U_table[idx0], V_table[idx0]
	u2, v2 := U_table[idx2], V_table[idx2]

	// Упаковка по спецификации v210
	w0 := (u0 & 0x3FF) | ((y0 & 0x3FF) << 10) | ((v0 & 0x3FF) << 20)
	w1 := (y1 & 0x3FF) | ((u2 & 0x3FF) << 10) | ((y2 & 0x3FF) << 20)
	w2 := (v2 & 0x3FF) | ((y3 & 0x3FF) << 10) | ((u0 & 0x3FF) << 20)
	w3 := uint32(0)

	byteIdx := blockID * 16
	binary.LittleEndian.PutUint32(dest[byteIdx+0:], w0)
	binary.LittleEndian.PutUint32(dest[byteIdx+4:], w1)
	binary.LittleEndian.PutUint32(dest[byteIdx+8:], w2)
	binary.LittleEndian.PutUint32(dest[byteIdx+12:], w3)
}
return nil

}

func (g *CPUGenerator) Close() error { return nil }

struct Uniforms { u_dt: f32, // Delta time for this frame (4 bytes) u_frame: u32, // Current frame number (4 bytes) };

@group(0) @binding(0) var uniforms: Uniforms;

struct VertexOutput { @builtin(position) position: vec4, @location(0) color: vec4, };

// We store the persistent position on the GPU using a global variable // so we can incrementally add 'dt' to it frame over frame. var squarePosition: vec2 = vec2(0.0, 0.0); var velocity: vec2 = vec2(0.4, 0.2); // Moves diagonally

@vertex fn vs_main(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput { var pos = array<vec2, 6>( vec2(-0.2, 0.2), vec2(-0.2, -0.2), vec2( 0.2, 0.2), vec2( 0.2, 0.2), vec2(-0.2, -0.2), vec2( 0.2, -0.2) );

var output: VertexOutput;

// 1. Frame-based logic: Change color every 60 frames
var color = vec4<f32>(0.3, 0.6, 0.9, 1.0); // Light blue
if ((uniforms.u_frame / 60u) % 2u == 0u) {
    color = vec4<f32>(0.9, 0.4, 0.3, 1.0); // Switch to Coral Red
}

// 2. DT-based logic: Calculate movement using uniform velocity * dt
// Note: Because vertex shaders run per-vertex, complex state changes are usually calculated 
// using total accumulated time or updated via JS. Here we use an explicit math function:
let speedMultiplier = 1.5;
let accumulatedGuess = f32(uniforms.u_frame) * uniforms.u_dt * speedMultiplier; 

let offsetX = sin(accumulatedGuess) * 0.5;
let offsetY = cos(accumulatedGuess * 0.5) * 0.3;

let animatedPosition = pos[vertexIndex] + vec2<f32>(offsetX, offsetY);

output.position = vec4<f32>(animatedPosition, 0.0, 1.0);
output.color = color;
return output;

}

@fragment fn fs_main(@location(0) color: vec4) -> @location(0) vec4 { return color; }

font

Option Kernel cost Quality Effort 1-bpp bitmap (current) 1 bit test blocky, aliased, integer scale only, 96 glyphs done 8-bpp AA atlas + 1 lerp smooth glyphs, fractional positioning, proper color blend ~1 hour — same architecture, atlas becomes grayscale, mix(bg, fg, a) instead of a bit test MSDF atlas (Valve/msdfgen) + 1 fetch + smoothstep crisp at any scale from one atlas, outlines/shadows/glow nearly free ~1 day — offline bake step, shader grows ~40 lines CPU rasterize → upload mask (x/image/font, any TTF) same as AA perfect (hinting, kerning, Unicode), arbitrary fonts at init ~half day + a dependency; CPU cost only on text change (timecode = once/sec, nothing)