Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Fewer allocations per request: positions for error locations are derived
on demand, nodes without arguments or directives share empty arrays, plain
selection sets are resolved without an intermediate field map, requests
without variables skip variable resolution, leaf fields and list elements
allocate no error arrays, and sequential list elements no longer create a
closure each. The benchmark reports 25 to 45 percent fewer bytes per request
and 18 to 46 percent more throughput across its scenarios

## [0.9.4] - 2026-10-09

### Fixed

- Objects implementing several interfaces are printed as
Expand Down
23 changes: 16 additions & 7 deletions bench/bench.cr
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,14 @@ module Bench
Scenario.new("introspection", GraphQL::INTROSPECTION_QUERY),
]

record Result, name : String, bytes : Int32, requests : Int32, seconds : Float64 do
record Result, name : String, bytes : Int32, requests : Int32, seconds : Float64, allocated : Int64 do
include JSON::Serializable

# heap bytes allocated per request, as reported by the GC
def allocated_per_request : Float64
allocated.to_f / requests
end

def per_second : Float64
requests / seconds
end
Expand All @@ -255,12 +260,15 @@ module Bench

requests = 0
elapsed = Time::Span.zero
GC.collect
allocated_before = GC.stats.total_bytes
while elapsed < budget
elapsed += Time.measure { SCHEMA.execute(scenario.query, scenario.variables, nil, scenario.context) }
requests += 1
end
allocated = (GC.stats.total_bytes - allocated_before).to_i64

Result.new(scenario.name, response.bytesize, requests, elapsed.total_seconds)
Result.new(scenario.name, response.bytesize, requests, elapsed.total_seconds, allocated)
end

def self.main
Expand All @@ -284,17 +292,18 @@ module Bench

puts "crystal #{Crystal::VERSION}, #{budget.total_seconds}s per scenario, release build: #{{{ flag?(:release) }}}"
puts
header = "%-18s %10s %10s %10s %8s" % ["scenario", "bytes", "req/s", "ms/req", "MB/s"]
header += " %10s" % "vs before" if baseline
header = "%-18s %10s %10s %10s %8s %12s" % ["scenario", "bytes", "req/s", "ms/req", "MB/s", "alloc/req"]
header += " %10s %10s" % ["req/s Δ", "alloc Δ"] if baseline
puts header
puts "-" * header.size

results = scenarios.map do |scenario|
result = run(scenario, budget)
line = "%-18s %10d %10.1f %10.3f %8.1f" % [result.name, result.bytes, result.per_second, result.ms_per_request, result.mb_per_second]
line = "%-18s %10d %10.1f %10.3f %8.1f %12s" % [result.name, result.bytes, result.per_second, result.ms_per_request, result.mb_per_second, result.allocated_per_request.round.to_i64.humanize_bytes]
if baseline && (before = baseline[result.name]?)
change = (result.per_second / before.per_second - 1) * 100
line += " %+9.1f%%" % change
speed = (result.per_second / before.per_second - 1) * 100
alloc = (result.allocated_per_request / before.allocated_per_request - 1) * 100
line += " %+9.1f%% %+9.1f%%" % [speed, alloc]
end
puts line
result
Expand Down
2 changes: 1 addition & 1 deletion shard.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: graphql
version: 0.9.3
version: 0.9.4
authors:
- Jakob Gillich <jakob@gillich.me>
license: MIT
Expand Down
45 changes: 39 additions & 6 deletions src/graphql/language/ast.cr
Original file line number Diff line number Diff line change
@@ -1,12 +1,43 @@
module GraphQL
module Language
abstract class ASTNode
# 1-based position in the query source, set by the parser on the
# nodes that errors refer to.
property line : Int32?
property column : Int32?
# Where the node starts in the query source, set by the parser on the
# nodes errors refer to. Line and column are derived on demand, since
# almost no node ever appears in an error.
@source : String?
@start : Int32?
@line : Int32?
@column : Int32?

def initialize(@line : Int32? = nil, @column : Int32? = nil)
def initialize(@source : String? = nil, @start : Int32? = nil)
end

# 1-based line of the node in the query, if known.
def line : Int32?
locate
@line
end

# 1-based column of the node in the query, if known.
def column : Int32?
locate
@column
end

private def locate : Nil
return if @line
return unless (source = @source) && (start = @start)
line = 1
line_start = 0
source.each_char_with_index do |char, index|
break if index >= start
if char == '\n'
line += 1
line_start = index + 1
end
end
@line = line
@column = start - line_start + 1
end

macro values(args)
Expand All @@ -18,8 +49,10 @@ module GraphQL
{%
assignments = args.map do |k, v|
if v.is_a?(Generic) && v.name.id == "Array"
# widen e.g. Array(Field) to Array(Selection); an array that
# already has the declared type is kept as is
type = v.type_vars.first.id
"@#{k.id} = #{k.id}.map(&.as(#{type}))"
"@#{k.id} = #{k.id}.is_a?(#{v.id}) ? #{k.id} : #{k.id}.map(&.as(#{type}))"
else
"@#{k.id} = #{k.id}"
end
Expand Down
93 changes: 44 additions & 49 deletions src/graphql/language/parser_context.cr
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
require "./token"

class GraphQL::Language::ParserContext
@line_starts : Array(Int32)?
# Shared by every node without arguments, directives, selections or
# variables; never mutated after parsing.
EMPTY_ARGUMENTS = [] of Language::Argument
EMPTY_DIRECTIVES = [] of Language::Directive
EMPTY_SELECTIONS = [] of Language::Selection
EMPTY_VARIABLE_DEFINITIONS = [] of Language::VariableDefinition

@current_token : Token
@descriptions = [] of String

Expand Down Expand Up @@ -42,14 +48,15 @@ class GraphQL::Language::ParserContext
parse_type
end

private def any(open : Token::Kind, next_proc : Proc(T), close : Token::Kind) : Array(T) forall T
# Zero or more items between `open` and `close`, each parsed by the block.
private def any(open : Token::Kind, close : Token::Kind, & : -> T) : Array(T) forall T
expect(open)

parse_comment

nodes = [] of T
while !skip(close)
nodes.push(next_proc.call)
nodes.push(yield)
end

nodes
Expand All @@ -62,74 +69,58 @@ class GraphQL::Language::ParserContext
)
end

# Line and column (1-based) of a character offset in the source.
private def location(start : Int32) : {Int32, Int32}
line_starts = @line_starts ||= begin
starts = [0]
@source.each_char_with_index { |c, i| starts << i + 1 if c == '\n' }
starts
end
line = line_starts.bsearch_index { |s| s > start } || line_starts.size
{line, start - line_starts[line - 1] + 1}
end

private def create_field(start : Int32, name : String, f_alias) : Language::Field
line, column = location(start)
Language::Field.new(
name: name,
_alias: f_alias,
arguments: parse_arguments,
directives: parse_directives,
selections: peek(Token::Kind::BRACE_L) ? parse_selection_set : [] of String,
line: line,
column: column,
selections: peek(Token::Kind::BRACE_L) ? parse_selection_set : EMPTY_SELECTIONS,
source: @source,
start: start,
)
end

private def create_graphql_fragment_spread(start)
line, column = location(start)
Language::FragmentSpread.new(
parse_fragment_name || raise(ParserError.new("Expected fragment name, found #{@current_token.kind}")),
parse_directives,
line: line,
column: column,
source: @source,
start: start,
)
end

private def create_inline_fragment(start)
line, column = location(start)
Language::InlineFragment.new(
get_type_condition,
parse_directives,
parse_selection_set,
line: line,
column: column,
source: @source,
start: start,
)
end

private def create_operation_definition(start, operation, name)
line, column = location(start)
Language::OperationDefinition.new(
operation_type: operation,
name: name,
variables: parse_variable_definitions,
directives: parse_directives,
selections: parse_selection_set,
line: line,
column: column,
source: @source,
start: start,
)
end

private def create_operation_definition(start)
line, column = location(start)
Language::OperationDefinition.new(
operation_type: "query",
name: nil,
variables: parse_variable_definitions,
directives: [] of Language::Directive,
variables: EMPTY_VARIABLE_DEFINITIONS,
directives: EMPTY_DIRECTIVES,
selections: parse_selection_set,
line: line,
column: column,
source: @source,
start: start,
)
end

Expand Down Expand Up @@ -188,14 +179,15 @@ class GraphQL::Language::ParserContext
type_condition
end

private def many(open, next_proc, close)
# One or more items between `open` and `close`, each parsed by the block.
private def many(open : Token::Kind, close : Token::Kind, & : -> T) : Array(T) forall T
expect(open)

parse_comment

nodes = [next_proc.call]
nodes = [yield]
while !skip(close)
nodes.push(next_proc.call)
nodes.push(yield)
end

nodes
Expand All @@ -213,11 +205,11 @@ class GraphQL::Language::ParserContext
return [] of Language::InputValueDefinition
end

many(Token::Kind::PAREN_L, -> { parse_input_value_def }, Token::Kind::PAREN_R)
many(Token::Kind::PAREN_L, Token::Kind::PAREN_R) { parse_input_value_def }
end

private def parse_arguments
peek(Token::Kind::PAREN_L) ? many(Token::Kind::PAREN_L, -> { parse_argument }, Token::Kind::PAREN_R) : [] of Language::Argument
peek(Token::Kind::PAREN_L) ? many(Token::Kind::PAREN_L, Token::Kind::PAREN_R) { parse_argument } : EMPTY_ARGUMENTS
end

private def parse_boolean_value(token)
Expand Down Expand Up @@ -338,6 +330,8 @@ class GraphQL::Language::ParserContext
end

private def parse_directives
return EMPTY_DIRECTIVES unless peek(Token::Kind::AT)

directives = [] of Language::Directive
while peek(Token::Kind::AT)
directives.push(parse_directive)
Expand All @@ -360,7 +354,7 @@ class GraphQL::Language::ParserContext
Language::EnumTypeDefinition.new(
name: get_name!,
directives: parse_directives,
fvalues: many(Token::Kind::BRACE_L, -> { parse_enum_value_definition }, Token::Kind::BRACE_R),
fvalues: many(Token::Kind::BRACE_L, Token::Kind::BRACE_R) { parse_enum_value_definition },
description: description,
)
end
Expand Down Expand Up @@ -473,7 +467,7 @@ class GraphQL::Language::ParserContext
Language::InputObjectTypeDefinition.new(
name: get_name!,
directives: parse_directives(),
fields: any(Token::Kind::BRACE_L, -> { parse_input_value_def }, Token::Kind::BRACE_R),
fields: any(Token::Kind::BRACE_L, Token::Kind::BRACE_R) { parse_input_value_def },
description: description,
)
end
Expand Down Expand Up @@ -506,16 +500,17 @@ class GraphQL::Language::ParserContext
Language::InterfaceTypeDefinition.new(
name: get_name!,
directives: parse_directives,
fields: any(Token::Kind::BRACE_L, -> { parse_field_definition }, Token::Kind::BRACE_R),
fields: any(Token::Kind::BRACE_L, Token::Kind::BRACE_R) { parse_field_definition },
description: description,
)
end

private def parse_list(is_constant) : Language::ArgumentValue
constant = Proc(Language::ArgumentValue).new { parse_constant_value }
value = Proc(Language::ArgumentValue).new { parse_value_value }

nested { any(Token::Kind::BRACKET_L, is_constant ? constant : value, Token::Kind::BRACKET_R) }
nested do
any(Token::Kind::BRACKET_L, Token::Kind::BRACKET_R) do
is_constant ? parse_constant_value : parse_value_value
end
end
end

private def parse_name : String?
Expand Down Expand Up @@ -610,7 +605,7 @@ class GraphQL::Language::ParserContext
description: description,
interfaces: parse_implements_interfaces,
directives: parse_directives,
fields: any(Token::Kind::BRACE_L, -> { parse_field_definition }, Token::Kind::BRACE_R),
fields: any(Token::Kind::BRACE_L, Token::Kind::BRACE_R) { parse_field_definition },
)
end

Expand Down Expand Up @@ -654,7 +649,7 @@ class GraphQL::Language::ParserContext
private def parse_schema_definition
expect_keyword("schema")
directives = parse_directives
definitions = many(Token::Kind::BRACE_L, -> { parse_operation_type_definition }, Token::Kind::BRACE_R)
definitions = many(Token::Kind::BRACE_L, Token::Kind::BRACE_R) { parse_operation_type_definition }

definitions = definitions.as(Array).reduce(Hash(String, String).new) do |memo, pair|
pair.as(Tuple(String, GraphQL::Language::TypeName)).tap { |p| memo[p[0]] = p[1].name }
Expand All @@ -674,7 +669,7 @@ class GraphQL::Language::ParserContext
end

private def parse_selection_set
nested { many(Token::Kind::BRACE_L, -> { parse_selection }, Token::Kind::BRACE_R) }
nested { many(Token::Kind::BRACE_L, Token::Kind::BRACE_R) { parse_selection } }
end

private def parse_string(is_constant)
Expand Down Expand Up @@ -782,9 +777,9 @@ class GraphQL::Language::ParserContext

private def parse_variable_definitions : Array(Language::VariableDefinition)
if peek(Token::Kind::PAREN_L)
many(Token::Kind::PAREN_L, -> { parse_variable_definition }, Token::Kind::PAREN_R)
many(Token::Kind::PAREN_L, Token::Kind::PAREN_R) { parse_variable_definition }
else
[] of Language::VariableDefinition
EMPTY_VARIABLE_DEFINITIONS
end
end

Expand Down
Loading
Loading