electra 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,475 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "grammar"
4
+
5
+ module Electra
6
+ # Grammar-driven instruction encoding and strict binary framing. Semantic
7
+ # validation is deliberately the job of spirv-val, not a second compiler.
8
+ module Binary
9
+ # SPIR-V binary header magic.
10
+ MAGIC = 0x07230203
11
+ # Universal maximum ID bound from the SPIR-V specification.
12
+ MAX_BOUND = 0x3fffff
13
+ module_function
14
+
15
+ def word(value)
16
+ raise Error, "expected an unsigned 32-bit word: #{value.inspect}" unless value.is_a?(Integer) && value.between?(0, 0xffffffff)
17
+ value
18
+ end
19
+
20
+ def string_words(value)
21
+ raise Error, "SPIR-V strings must be UTF-8 without NUL" unless value.is_a?(String) && value.dup.force_encoding(Encoding::UTF_8).valid_encoding? && !value.include?("\0")
22
+ bytes = value.b + "\0"
23
+ (bytes + "\0" * ((-bytes.bytesize) % 4)).unpack("V*")
24
+ end
25
+
26
+ # Encode the SPIR-V textual string escaping rules (not JSON escaping).
27
+ def quote(value)
28
+ '"' + value.gsub(/["\\]/) { |character| "\\#{character}" } + '"'
29
+ end
30
+
31
+ def unquote(token)
32
+ raise Error, "unterminated string" unless token.end_with?('"') && token.length >= 2
33
+ token[1...-1].gsub(/\\([\s\S])/) { Regexp.last_match(1) }
34
+ end
35
+
36
+ def enum(kind, value)
37
+ category, entries = OPERAND_KINDS.fetch(kind)
38
+ return [word(value), []] if value.is_a?(Integer) && !entries.values.any? { |entry| entry[0] == value }
39
+ if value.is_a?(Integer)
40
+ names = if category == "BitEnum" && value != 0
41
+ entries.select { |_, (number, _)| number != 0 && (value & number) == number }.keys
42
+ else
43
+ [entries.key(entries.values.find { |entry| entry[0] == value })]
44
+ end
45
+ else
46
+ names = value.to_s.split("|")
47
+ end
48
+ raise Error, "invalid #{kind}: #{value.inspect}" if names.empty? || (category != "BitEnum" && names.size != 1)
49
+ number = 0
50
+ parameters = []
51
+ names.each do |name|
52
+ entry = entries[name] or raise Error, "unknown #{kind} #{name}"
53
+ number |= entry[0]
54
+ parameters.concat(entry[1])
55
+ end
56
+ [number, parameters]
57
+ end
58
+
59
+ def encode(name, values)
60
+ opcode, schema = INSTRUCTIONS.fetch(name.to_s) { raise Error, "unknown instruction #{name}" }
61
+ words = encode_operands(name, schema, values)
62
+ raise Error, "instruction too long" if words.size >= 0xffff
63
+ [(words.size + 1) << 16 | opcode, *words]
64
+ end
65
+
66
+ def encode_operands(name, schema, values)
67
+ words = []
68
+ index = 0
69
+ walk = lambda do |operands|
70
+ operands.each do |kind, quantifier|
71
+ count = quantifier == "*" ? values.size - index : 1
72
+ count = 0 if quantifier == "?" && index == values.size
73
+ count.times do
74
+ raise Error, "missing #{kind} in #{name}" if index >= values.size
75
+ category, = OPERAND_KINDS.fetch(kind)
76
+ if category == "Composite"
77
+ walk.call(OPERAND_KINDS.fetch(kind)[2].map { |base| [base, ""] })
78
+ break if quantifier == "*" && index == values.size
79
+ next
80
+ end
81
+ value = values[index]
82
+ index += 1
83
+ case category
84
+ when "Id"
85
+ value = value.to_i if value.respond_to?(:to_i) && !value.is_a?(String)
86
+ raise Error, "invalid id #{value.inspect}" unless value.is_a?(Integer) && value.between?(1, MAX_BOUND - 1)
87
+ words << value
88
+ when "ValueEnum", "BitEnum"
89
+ number, parameters = enum(kind, value)
90
+ words << number
91
+ walk.call(parameters)
92
+ else
93
+ if kind == "LiteralString"
94
+ words.concat(string_words(value))
95
+ elsif kind == "LiteralSpecConstantOpInteger"
96
+ number = value.is_a?(Integer) ? value : INSTRUCTIONS.fetch("Op#{value.to_s.delete_prefix('Op')}") { raise Error, "unknown specialization opcode" }[0]
97
+ words << word(number)
98
+ definition = fetch_spec_opcode(number)
99
+ walk.call(definition[1].reject { |operand, _| ["IdResultType", "IdResult"].include?(operand) })
100
+ elsif kind == "LiteralFloat" && !value.is_a?(Integer)
101
+ words << float_bits(Float(value))
102
+ elsif kind == "LiteralContextDependentNumber" && value.is_a?(Array)
103
+ words.concat(value.map { |item| word(item) })
104
+ else
105
+ words << word(value)
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
111
+ walk.call(schema)
112
+ raise Error, "extra operands in #{name}" unless index == values.size
113
+ words
114
+ end
115
+
116
+ def read(binary)
117
+ raise Error, "SPIR-V must be a word-aligned binary String" unless binary.is_a?(String) && binary.bytesize >= 20 && binary.bytesize % 4 == 0
118
+ little = binary.unpack1("V") == MAGIC
119
+ words = binary.unpack(little ? "V*" : "N*")
120
+ raise Error, "invalid SPIR-V magic" unless words[0] == MAGIC
121
+ version = words[1]
122
+ raise Error, "unsupported SPIR-V version" unless (version & 0xff0000ff).zero? && (version >> 16) == 1 && ((version >> 8) & 255) <= 6
123
+ raise Error, "invalid id bound" unless words[3].between?(1, MAX_BOUND)
124
+ raise Error, "reserved schema must be zero" unless words[4].zero?
125
+
126
+ [words.first(5), read_instructions(words), little]
127
+ end
128
+
129
+ def read_instructions(words)
130
+ instructions = []
131
+ offset = 5
132
+ while offset < words.size
133
+ count, opcode = words[offset] >> 16, words[offset] & 0xffff
134
+ raise Error, "invalid instruction length at word #{offset}" if count.zero? || count > words.size - offset
135
+ raise Error, "unknown opcode #{opcode}" unless OPCODES.key?(opcode)
136
+ instructions << [opcode, words.slice(offset + 1, count - 1)]
137
+ offset += count
138
+ end
139
+ instructions
140
+ end
141
+
142
+ def decode(opcode, words, bound:, types: {})
143
+ name, schema = OPCODES.fetch(opcode)
144
+ tokens, result = decode_operands(name, schema, words, bound:, types:)
145
+ record_type_declaration(types, name, words)
146
+ types[[:import, words[0]]] = unquote(tokens[0]) if name == "OpExtInstImport"
147
+ [name, tokens, result]
148
+ end
149
+
150
+ def decode_operands(name, schema, words, bound:, types:)
151
+ tokens = []
152
+ index = 0
153
+ result = nil
154
+ result_type = nil
155
+ take = lambda do
156
+ raise Error, "truncated #{name}" if index >= words.size
157
+ value = words[index]
158
+ index += 1
159
+ value
160
+ end
161
+ walk = lambda do |operands|
162
+ operands.each do |kind, quantifier|
163
+ loop do
164
+ break if ["?", "*"].include?(quantifier) && index == words.size
165
+ category, entries, bases = OPERAND_KINDS.fetch(kind)
166
+ if category == "Composite"
167
+ walk.call(bases.map { |base| [base, ""] })
168
+ elsif kind == "LiteralString"
169
+ tokens << decode_string(take)
170
+ elsif kind == "LiteralContextDependentNumber"
171
+ tokens << decode_number(take, types[result_type])
172
+ else
173
+ number = take.call
174
+ if category == "Id"
175
+ raise Error, "id #{number} outside bound #{bound}" unless number.between?(1, bound - 1)
176
+ result_type = number if kind == "IdResultType"
177
+ if kind == "IdResult"
178
+ result = "%#{number}"
179
+ else
180
+ tokens << "%#{number}"
181
+ end
182
+ elsif ["ValueEnum", "BitEnum"].include?(category)
183
+ selected = if category == "BitEnum" && number != 0
184
+ entries.select { |_, (value, _)| value != 0 && (number & value) == value }
185
+ else
186
+ entries.select { |_, (value, _)| value == number }.first(1).to_h
187
+ end
188
+ matched = selected.values.reduce(0) { |mask, (value, _)| mask | value }
189
+ if selected.empty? || matched != number
190
+ tokens << number.to_s
191
+ else
192
+ tokens << selected.keys.join("|")
193
+ selected.each_value { |_, parameters| walk.call(parameters) }
194
+ end
195
+ else
196
+ if kind == "LiteralSpecConstantOpInteger"
197
+ definition = fetch_spec_opcode(number)
198
+ tokens << definition[0].delete_prefix("Op")
199
+ walk.call(definition[1].reject { |operand, _| ["IdResultType", "IdResult"].include?(operand) })
200
+ elsif kind == "LiteralExtInstInteger" && types[[:import, words[2]]] == "GLSL.std.450"
201
+ tokens << (GLSL_INSTRUCTIONS.key(number) || number.to_s)
202
+ elsif kind == "LiteralFloat"
203
+ tokens << float_text(number, 32)
204
+ else
205
+ tokens << number.to_s
206
+ end
207
+ end
208
+ end
209
+ break unless quantifier == "*"
210
+ end
211
+ end
212
+ end
213
+ walk.call(schema)
214
+ raise Error, "extra words in #{name}" unless index == words.size
215
+ [tokens, result]
216
+ end
217
+
218
+ def decode_string(take)
219
+ bytes = +"".b
220
+ loop do
221
+ chunk = [take.call].pack("V")
222
+ nul = chunk.index("\0")
223
+ if nul
224
+ raise Error, "nonzero string padding" unless chunk.bytes.drop(nul).all?(&:zero?)
225
+ bytes << chunk.byteslice(0, nul)
226
+ break
227
+ end
228
+ bytes << chunk
229
+ end
230
+ bytes.force_encoding(Encoding::UTF_8)
231
+ raise Error, "invalid UTF-8 string" unless bytes.valid_encoding?
232
+ quote(bytes)
233
+ end
234
+
235
+ def decode_number(take, type)
236
+ width = type ? type[1] : 32
237
+ raise Error, "unsupported scalar width #{width}" unless [16, 32, 64].include?(width)
238
+ bits = take.call
239
+ bits |= take.call << 32 if width == 64
240
+ return float_text(bits, width) if type && type[0] == :float
241
+ bits -= 1 << width if type && type[2] == 1 && bits[width - 1] == 1
242
+ bits.to_s
243
+ end
244
+
245
+ def fetch_spec_opcode(number)
246
+ definition = OPCODES[number] or raise Error, "unknown specialization opcode"
247
+ raise Error, "recursive specialization instruction" if definition[1].any? { |operand, _| operand == "LiteralSpecConstantOpInteger" }
248
+ definition
249
+ end
250
+
251
+ # SPIR-V's hexadecimal float syntax represents infinity/NaN with exponent
252
+ # one above the finite range, retaining payload bits for exact round trips.
253
+ def float_text(bits, width)
254
+ exponent_bits, fraction_bits = {16 => [5, 10], 32 => [8, 23], 64 => [11, 52]}.fetch(width)
255
+ sign = bits[width - 1] == 1 ? "-" : ""
256
+ exponent = (bits >> fraction_bits) & ((1 << exponent_bits) - 1)
257
+ fraction = bits & ((1 << fraction_bits) - 1)
258
+ if exponent == (1 << exponent_bits) - 1
259
+ digits = (fraction_bits + 3) / 4
260
+ mantissa = (fraction << (digits * 4 - fraction_bits)).to_s(16).rjust(digits, "0")
261
+ "#{sign}0x1.#{mantissa}p+#{1 << (exponent_bits - 1)}"
262
+ elsif width == 16
263
+ value = (exponent.zero? ? fraction : fraction + (1 << fraction_bits)) * 2.0**((exponent.zero? ? 1 : exponent) - 15 - fraction_bits)
264
+ format("%a", sign.empty? ? value : -value)
265
+ else
266
+ value = [bits].pack(width == 32 ? "V" : "Q<").unpack1(width == 32 ? "e" : "E")
267
+ format("%a", value)
268
+ end
269
+ end
270
+
271
+ # Round-to-nearest, ties-to-even from Ruby's Float64. String#pack('e') on
272
+ # some Ruby versions overflows just-above-FLT_MAX values prematurely,
273
+ # including the nine-digit decimal spelling emitted by spirv-dis.
274
+ def float_bits(value, width = 32)
275
+ return [value.to_f].pack("E").unpack1("Q<") if width == 64
276
+ exponent_bits, fraction_bits = {16 => [5, 10], 32 => [8, 23]}.fetch(width)
277
+ source = [value.to_f].pack("E").unpack1("Q<")
278
+ sign = (source >> 63) << (width - 1)
279
+ exponent = source >> 52 & 0x7ff
280
+ fraction = source & ((1 << 52) - 1)
281
+ exponent_max = (1 << exponent_bits) - 1
282
+ if exponent == 0x7ff
283
+ payload = fraction >> (52 - fraction_bits)
284
+ payload = 1 if fraction != 0 && payload.zero?
285
+ return sign | (exponent_max << fraction_bits) | payload
286
+ end
287
+ return sign if exponent.zero? && fraction.zero?
288
+ target_exponent = (exponent.zero? ? 1 : exponent) - 1023 + (1 << (exponent_bits - 1)) - 1
289
+ mantissa = exponent.zero? ? fraction : fraction | (1 << 52)
290
+ shift = 52 - fraction_bits + [1 - target_exponent, 0].max
291
+ rounded = mantissa >> shift
292
+ remainder = mantissa & ((1 << shift) - 1)
293
+ halfway = 1 << (shift - 1)
294
+ rounded += 1 if remainder > halfway || (remainder == halfway && rounded.odd?)
295
+ if target_exponent <= 0
296
+ return sign | rounded
297
+ end
298
+ if rounded >= 1 << (fraction_bits + 1)
299
+ rounded >>= 1
300
+ target_exponent += 1
301
+ end
302
+ return sign | (exponent_max << fraction_bits) if target_exponent >= exponent_max
303
+ sign | (target_exponent << fraction_bits) | (rounded & ((1 << fraction_bits) - 1))
304
+ end
305
+
306
+ # Internal implementation of Electra.disassemble.
307
+ def disassemble(binary)
308
+ header, instructions, little = read(binary)
309
+ types = {}
310
+ lines = ["; SPIR-V", "; Version: #{header[1] >> 16}.#{header[1] >> 8 & 255}", "; Generator: #{header[2]}", "; Bound: #{header[3]}", "; Schema: #{header[4]}", "; Endian: #{little ? 'little' : 'big'}"]
311
+ instructions.each do |opcode, words|
312
+ name, tokens, result = decode(opcode, words, bound: header[3], types: types)
313
+ lines << [result && "#{result} =", name, *tokens].compact.join(" ")
314
+ end
315
+ lines.join("\n") + "\n"
316
+ end
317
+
318
+ def assemble(source)
319
+ raise Error, "assembly must be a String" unless source.is_a?(String)
320
+ parsed_statements = statements(source)
321
+ version, generator, bound, schema, little = parse_header(parsed_statements)
322
+ words, max_id = encode_statements(parsed_statements)
323
+ header = [MAGIC, version, generator, bound || max_id + 1, schema].map { |value| word(value) }
324
+ binary = [*header, *words].pack(little ? "V*" : "N*")
325
+ disassemble(binary) # Validate framing, operands and bounds before returning.
326
+ binary
327
+ end
328
+
329
+ def parse_header(statements)
330
+ version, generator, bound, schema, little = 0x10000, 0, nil, 0, true
331
+ statements.each do |_, tokens|
332
+ case tokens.first
333
+ when /\A; Version: (\d+)\.(\d+)/ then version = ($1.to_i << 16) | ($2.to_i << 8)
334
+ when /\A; Generator: (\d+)\s*$/ then generator = $1.to_i
335
+ when /\A; Bound: (\d+)/ then bound = $1.to_i
336
+ when /\A; Schema: (\d+)/ then schema = $1.to_i
337
+ when /\A; Endian: big/ then little = false
338
+ end
339
+ end
340
+ [version, generator, bound, schema, little]
341
+ end
342
+
343
+ def encode_statements(statements)
344
+ types = {}
345
+ max_id = 0
346
+ words = statements.each_with_object([]) do |(line_number, tokens), result|
347
+ next if tokens.first.start_with?(";")
348
+ instruction, instruction_max_id = encode_statement(tokens, line_number, types)
349
+ result.concat(instruction)
350
+ max_id = [max_id, instruction_max_id].max
351
+ rescue ArgumentError, TypeError => error
352
+ raise Error, "line #{line_number}: #{error.message}"
353
+ end
354
+ [words, max_id]
355
+ end
356
+
357
+ def encode_statement(tokens, line_number, types)
358
+ name, tokens = parse_instruction(tokens, line_number)
359
+ values, max_id = parse_operands(tokens)
360
+ record_type_declaration(types, name, values)
361
+ types[[:import, values[0]]] = values[1] if name == "OpExtInstImport"
362
+ resolve_context_dependent_operands(name, tokens, values, types)
363
+ [encode(name, values), max_id]
364
+ end
365
+
366
+ def parse_instruction(tokens, line_number)
367
+ result, name, operands = tokens[1] == "=" ? [tokens[0], tokens[2], tokens.drop(3)] : [nil, tokens[0], tokens.drop(1)]
368
+ definition = INSTRUCTIONS[name] or raise Error, "unknown instruction #{name} at line #{line_number}"
369
+ result_index = definition[1].index { |kind, _| kind == "IdResult" }
370
+ if result_index
371
+ raise Error, "missing result id at line #{line_number}" unless result
372
+ operands.insert(result_index, result)
373
+ elsif result
374
+ raise Error, "unexpected result id at line #{line_number}"
375
+ end
376
+ [name, operands]
377
+ end
378
+
379
+ def parse_operands(tokens)
380
+ max_id = 0
381
+ values = tokens.map do |token|
382
+ value = parse_operand(token)
383
+ max_id = [max_id, value].max if token.start_with?("%")
384
+ value
385
+ end
386
+ [values, max_id]
387
+ end
388
+
389
+ def record_type_declaration(types, name, values)
390
+ types[values[0]] = [:float, values[1]] if name == "OpTypeFloat"
391
+ types[values[0]] = [:int, values[1], values[2]] if name == "OpTypeInt"
392
+ end
393
+
394
+ def resolve_context_dependent_operands(name, tokens, values, types)
395
+ if name == "OpExtInst" && types[[:import, values[2]]] == "GLSL.std.450" && values[3].is_a?(String)
396
+ values[3] = GLSL_INSTRUCTIONS.fetch(values[3]) { raise Error, "unknown GLSL instruction #{values[3]}" }
397
+ end
398
+ if ["OpConstant", "OpSpecConstant"].include?(name)
399
+ type = types[values[0]] or raise Error, "constant type must be declared first"
400
+ values[2] = number_words(tokens[2], type)
401
+ end
402
+ end
403
+
404
+ def parse_operand(token)
405
+ if token.start_with?("%")
406
+ raise Error, "only numeric ids are supported" unless token.match?(/\A%[1-9]\d*\z/)
407
+ token.delete_prefix("%").to_i
408
+ elsif token.start_with?('"')
409
+ unquote(token)
410
+ elsif token.match?(/\A-?(?:0x[\da-f]+|\d+)\z/i)
411
+ Integer(token, 0) rescue Integer(token, 10)
412
+ else
413
+ token
414
+ end
415
+ end
416
+
417
+ # Tokenize statements, preserving multiline strings and ignoring comments.
418
+ def statements(source)
419
+ result = []
420
+ tokens = []
421
+ line_number = 1
422
+ first_line = 1
423
+ source.scan(/"(?:\\[\s\S]|[^"\\])*"|;[^\r\n]*|\r?\n|[^\s;]+/).each do |token|
424
+ if token.start_with?(";")
425
+ result << [line_number, [token]] if tokens.empty?
426
+ elsif token == "\n" || token == "\r\n"
427
+ result << [first_line, tokens] unless tokens.empty?
428
+ tokens = []
429
+ line_number += 1
430
+ first_line = line_number
431
+ else
432
+ tokens << token
433
+ line_number += token.count("\n")
434
+ end
435
+ end
436
+ result << [first_line, tokens] unless tokens.empty?
437
+ result
438
+ rescue ArgumentError => error
439
+ raise Error, "invalid assembly encoding: #{error.message}"
440
+ end
441
+
442
+ # Encode context-dependent integer/float literal words for a scalar type.
443
+ def number_words(token, type)
444
+ kind, width, = type
445
+ bits = kind == :float ? float_literal_bits(token, width) : integer_literal_bits(token, width)
446
+ width <= 32 ? [bits] : [bits & 0xffffffff, bits >> 32]
447
+ end
448
+
449
+ def float_literal_bits(token, width)
450
+ raise Error, "assembly supports 16/32/64-bit float constants" unless [16, 32, 64].include?(width)
451
+ exponent_bits, fraction_bits = {16 => [5, 10], 32 => [8, 23], 64 => [11, 52]}.fetch(width)
452
+ max_exponent = 1 << (exponent_bits - 1)
453
+ match = token.match(/\A(-?)0x1(?:\.([\da-f]+))?p\+#{max_exponent}\z/i)
454
+ return float_bits(Float(token), width) unless match
455
+
456
+ digits = match[2] || "0"
457
+ fraction = Integer(digits, 16) << fraction_bits
458
+ fraction >>= digits.size * 4
459
+ bits = (((1 << exponent_bits) - 1) << fraction_bits) | fraction
460
+ match[1] == "-" ? bits | (1 << (width - 1)) : bits
461
+ end
462
+
463
+ def integer_literal_bits(token, width)
464
+ value = Integer(token, 0) rescue Integer(token, 10)
465
+ raise Error, "integer constant outside #{width}-bit range" unless value.between?(-(1 << (width - 1)), (1 << width) - 1)
466
+ value & ((1 << width) - 1)
467
+ end
468
+
469
+ private_class_method :encode_operands, :read_instructions, :decode_operands, :decode_string,
470
+ :decode_number, :fetch_spec_opcode, :parse_header,
471
+ :encode_statements, :encode_statement, :parse_instruction, :parse_operands,
472
+ :record_type_declaration, :resolve_context_dependent_operands, :parse_operand,
473
+ :float_literal_bits, :integer_literal_bits
474
+ end
475
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ruby 3.2 added Data; Struct provides the same record operations used here.
4
+ unless defined?(Data)
5
+ Data = Struct
6
+ def Data.define(*members, &block)
7
+ Struct.new(*members) do
8
+ members.each { |member| undef_method :"#{member}=" }
9
+ define_method(:initialize) do |*values, **keywords|
10
+ if keywords.any?
11
+ raise ArgumentError, "expected either positional or keyword members" unless values.empty? && keywords.keys.sort == members.sort
12
+ values = members.map { |member| keywords.fetch(member) }
13
+ end
14
+ raise ArgumentError, "wrong number of members" unless values.length == members.length
15
+ super(*values)
16
+ freeze
17
+ end
18
+ define_method(:with) do |**changes|
19
+ changes.empty? ? self : self.class.new(**to_h.merge(changes))
20
+ end
21
+ class_eval(&block) if block
22
+ end
23
+ end
24
+ end
25
+